From 8279b038c4c7683fd7b5af7ed82c3f6878c590e0 Mon Sep 17 00:00:00 2001 From: byte271 Date: Wed, 4 Nov 2026 12:41:31 -0500 Subject: [PATCH] research: add boundary-aware constrained reconstruction prototype Add a dependency-free experimental candidate-lattice planner with bounded exact multi-view search, cross-sentence window accounting, fixed source normalization, and a narrow reporting-frame frontend. Do not alter the production CLI, Unicode carriers, frozen evidence, or workflow triggers. Local verification: 102 new tests and 70 existing CLI/machine-text tests pass. On 128 synthetic grammar fixtures, joint search improves worst-view source window retention over an identical-budget coordinate-greedy baseline on 51 cases; 24 exhaustive comparisons agree. This is a geometry mechanism test, NOT a real LLM-watermark removal result. Scope and failure behavior are explicit in receipts and documentation. --- research/reconstruction/README.md | 145 ++++++++++++++ research/reconstruction/__init__.py | 3 + research/reconstruction/__main__.py | 47 +++++ research/reconstruction/benchmark.py | 158 +++++++++++++++ research/reconstruction/engine.py | 211 ++++++++++++++++++++ research/reconstruction/legacy_control.py | 53 +++++ research/reconstruction/planner.py | 180 +++++++++++++++++ research/reconstruction/protocol.json | 15 ++ research/reconstruction/results.json | 53 +++++ tests/test_reconstruction.py | 223 ++++++++++++++++++++++ 10 files changed, 1088 insertions(+) create mode 100644 research/reconstruction/README.md create mode 100644 research/reconstruction/__init__.py create mode 100644 research/reconstruction/__main__.py create mode 100644 research/reconstruction/benchmark.py create mode 100644 research/reconstruction/engine.py create mode 100644 research/reconstruction/legacy_control.py create mode 100644 research/reconstruction/planner.py create mode 100644 research/reconstruction/protocol.json create mode 100644 research/reconstruction/results.json create mode 100644 tests/test_reconstruction.py diff --git a/research/reconstruction/README.md b/research/reconstruction/README.md new file mode 100644 index 00000000..ed943b95 --- /dev/null +++ b/research/reconstruction/README.md @@ -0,0 +1,145 @@ +# Boundary-aware, constraint-first reconstruction: experimental v1 + +**This is a working research prototype, not a verified LLM-watermark remover.** +It changes ordinary visible wording. It does not extend the Unicode carrier stack, +change the production CLI, ship a detector product, or claim a novel watermark attack. + +## What actually runs + +The end-to-end frontend supports a deliberately narrow English reporting grammar: + +```text +Orion Labs reported a revenue of 120 USD in 2025. +Vega Systems did not report a cost of 42 EUR in 2024. +``` + +Five metric families are supported: revenue/cost, distance, duration, and mass, +with explicit compatible units. Amounts are nonnegative decimal literals. Entities +follow a bounded ASCII proper-name grammar; years are 1900-2199. Positive and +negative reporting statements are accepted in four active/passive/time-fronted +forms. Unsupported prose, quotations, code, hidden characters, and languages are +not silently rewritten. The text API returns the original with `unsupported`; +the CLI returns exit 3. This is not an arbitrary-prose semantic parser. + +Each accepted sentence becomes an ordered frame: +`(entity, metric, exact amount literal, unit, year, negated)`. +All alternatives must reparse to the same frame. The output is checked again +against the ordered frames and numeric literals. This is stronger than matching +an unordered bag of numbers, but it proves only the restricted grammar contract, +not the truth of assertions or semantic equivalence for unrestricted language. + +The generic low-level planner accepts finite candidate lattices. It is **not** +a semantic verifier; do not feed unchecked LLM paraphrases into it and treat its +score as a quality guarantee. The included frontend supplies grammar-checked +candidates. Connecting a general-purpose generator/semantic verifier remains work +not implemented here. + +## Algorithm, not another hidden-character layer + +For each supported sentence, construct a finite candidate set. Search their +combinations jointly under a fixed normalized-word edit budget. The state carries +last `k-1` tokens for every measurement view, accumulated retained-window counts, +and edit cost. Extending a state scores both interior and cross-sentence windows. + +The objective is lexicographic: + +1. Minimize the largest source-normalized retained-window count across views. +2. Minimize the sum of those fractions. +3. Minimize edit cost, then choose a deterministic candidate-index path. + +The denominator is fixed from the **source**, so adding filler cannot dilute the +score. A retained output window is counted when its tuple occurs anywhere in the +source, including at a different location. Repetition is counted with output +multiplicity; this is not an implementation of SynthID's repetition masking. + +Pareto pruning is allowed only for equal tail states: a state can dominate another +only when its cost and every accumulated count are no larger. Future increments +are then identical for equal suffix choices. Exactness is conditional on the given +finite candidate lattice, supported tokenizers, objective, and successful bounded +completion. It is not an optimum over all possible rewrites. + +A full-output recomputation must agree with the dynamic program. Unsupported +non-compositional tokenizers, including an arbitrary BPE callback, are rejected +rather than falsely declared exact. State/evaluation limits return the unchanged +source and `search_limit`; no truncated search is labeled optimal. + +Current views are NFKC/casefold word 3-grams, word 5-grams, and word-plus-punctuation +5-grams. These are proxies, **not GPT-2/BPE observations or watermark scores**. +There are no watermark keys, detector queries, model/API calls, or new runtime +dependencies in candidate generation and selection. + +## Run from a repository checkout + +```sh +python -m research.reconstruction --text "Orion Labs reported a revenue of 120 USD in 2025. Vega Systems did not report a cost of 42 EUR in 2024." --json +python -m research.reconstruction --file input.txt --budget 32 --json +python -m pytest -q tests/test_reconstruction.py +python -m research.reconstruction.benchmark --output geometry-128.json +python -m research.reconstruction.legacy_control --output legacy-control.json +``` + +The research directory intentionally is not included in the current `fuckmark*` +wheel package selection. Existing `fuckmark` commands and evidence are unchanged. +Exit 0 means a supported reconstruction finished, not that a watermark was removed. +Exit 2 is invalid input/configuration; exit 3 is unsupported input or a search limit. +JSON receipts always include `watermark_removal_verified: false`. + +## Measured results, and their exact scope + +Local environment: CPython 3.13.5, Linux. New tests: **102 passed**. Existing CLI, +CLI I/O, and protected-machine-text tests: **70 passed**. Combined: **172 passed**. +This is targeted local validation, not a complete checkout CI result. + +The protocol was written before the first development benchmark. The 128 fixtures +are newly synthesized controlled-grammar statements, **not watermarked LLM text**. +No previous frozen confirmation corpus was used. The greedy comparator uses the +same alternatives and budget, recomputes whole-output geometry, and changes one +candidate position at a time. It is not the repository's historical scheduler. + +| Measurement | Result | +| --- | --- | +| Supported documents reconstructed | 128 / 128 | +| Ordered grammar-frame checks | 128 / 128 | +| Joint search strictly better than greedy on worst-view retention | 51 / 128 | +| Tied on worst-view retention | 77 / 128 | +| Mean worst-view retention, joint search | 0.5987639731 | +| Mean worst-view retention, greedy | 0.6205182055 | +| Exhaustive-search agreement on benchmark fixtures | 24 / 24 | +| Additional randomized exhaustive-search test cases | 48 / 48 | +| Generated frame/form roundtrips tested | 2,048 | +| Real watermark-detector evaluation | **Not run** | + +Canonical benchmark row digest: +`36d173f4e1521f74a963754c917724eaf4e5ba37b62201feb662a5510f495736`. +The benchmark test pins it; elapsed time is excluded. `results.json` contains the +summary. Re-running the command produces all source/output texts and receipts. + +In a separate control against baseline commit +`09c8cbf184e37e2e934e133b74a5db9af67b4f97`, exact deletion of known FuckMark carriers +restored all 128 legacy outputs. All 128 new outputs survived NFKC plus that same +carrier deletion unchanged. This demonstrates the new representation does not +rely on those carriers. It does **not** prove resistance to arbitrary normalization, +semantic rewrites, a tokenizer, or an actual watermark detector. + +## Limits and the next scientific gate + +The local runtime lacked pretrained model/tokenizer artifacts, and outbound DNS +failed. No real SynthID, KGW, proprietary detector, cross-tokenizer, or real-LLM +removal claim is made. Proxy optimization can fail to reduce a real detector score; +protected facts and short texts can retain strong signals. Meaning-preserving +reconstruction may also fail against semantic watermark designs. + +Before product promotion: integrate an independently checked general-text +candidate provider; use genuine tokenizer handling with boundary verification; +freeze fresh model/watermark/threshold configurations; retain unsupported cases +and failures in the denominator; and measure fidelity and detection separately. +Neither the production CLI nor frozen scientific records should change merely +because this mechanism benchmark passes. + +Prior work already studies paraphrase and character-level watermark attacks: +- https://arxiv.org/abs/2508.20228 +- https://arxiv.org/abs/2509.09112 + +This prototype's measured contribution is a small, reproducible constrained +reconstruction implementation with exact joint selection on its finite lattice. +A broader novelty or watermark-efficacy claim remains unestablished. diff --git a/research/reconstruction/__init__.py b/research/reconstruction/__init__.py new file mode 100644 index 00000000..6de88599 --- /dev/null +++ b/research/reconstruction/__init__.py @@ -0,0 +1,3 @@ +from .engine import Frame, Reconstruction, parse_frame, reconstruct, reconstruct_frames + +__all__ = ['Frame', 'Reconstruction', 'parse_frame', 'reconstruct', 'reconstruct_frames'] diff --git a/research/reconstruction/__main__.py b/research/reconstruction/__main__.py new file mode 100644 index 00000000..e047dbb6 --- /dev/null +++ b/research/reconstruction/__main__.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from .engine import reconstruct + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Experimental fact-preserving reconstruction, not verified watermark removal') + parser.add_argument('--text') + parser.add_argument('--file', type=Path) + parser.add_argument('--budget', type=int, default=32) + parser.add_argument('--json', action='store_true') + parser.add_argument('--max-states', type=int, default=20000) + args = parser.parse_args(argv) + if args.text is not None and args.file is not None: + parser.error('choose --text or --file') + try: + if args.text is not None: + source = args.text + elif args.file is not None: + with args.file.open(encoding='utf-8', newline='') as stream: + source = stream.read(100001) + else: + buffer = getattr(sys.stdin, 'buffer', None) + source = buffer.read(400001).decode('utf-8') if buffer is not None else sys.stdin.read(100001) + result = reconstruct(source, budget=args.budget, max_states=args.max_states) + if args.json: + print(json.dumps({'text': result.text, 'receipt': result.receipt()}, ensure_ascii=True, indent=2)) + else: + buffer = getattr(sys.stdout, 'buffer', None) + if buffer is None: + sys.stdout.write(result.text) + else: + buffer.write(result.text.encode('utf-8')) + buffer.flush() + sys.stderr.write(json.dumps(result.receipt(), ensure_ascii=True) + '\n') + return 0 if result.status in {'changed', 'unchanged'} else 3 + except (OSError, UnicodeError, ValueError, TypeError) as error: + print(f'reconstruction: {error}', file=sys.stderr) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/research/reconstruction/benchmark.py b/research/reconstruction/benchmark.py new file mode 100644 index 00000000..8554ef2d --- /dev/null +++ b/research/reconstruction/benchmark.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from dataclasses import asdict +from itertools import product +from pathlib import Path +from fractions import Fraction +from statistics import mean +import argparse +import hashlib +import json +import random +import time + +from .engine import Frame, parse_frame, render, reconstruct_frames, edit_distance +from .planner import Alternative, DEFAULT_VIEWS, reference, residual_counts, objective, words + +SEED = 27112401 +BENCHMARK_ID = 'reconstruction-geometry-sanity-v1' + + +def _metric(text, refs, sizes, cost, path): + return objective(residual_counts(text, refs, DEFAULT_VIEWS), sizes, cost, tuple(path)) + + +def _greedy(source, lattice, budget): + refs, sizes = reference(source, DEFAULT_VIEWS) + chosen = tuple(next(i for i, a in enumerate(row) if a.cost == 0) for row in lattice) + current = _metric(source, refs, sizes, 0, chosen) + evaluations = 1 + while True: + best = None + for position, row in enumerate(lattice): + for choice, alternative in enumerate(row): + path = list(chosen) + path[position] = choice + cost = sum(lattice[i][j].cost for i, j in enumerate(path)) + if cost > budget: + continue + text = ' '.join(lattice[i][j].text for i, j in enumerate(path)) + score = _metric(text, refs, sizes, cost, path) + evaluations += 1 + if score < current and (best is None or score < best[0]): + best = (score, tuple(path), text) + if best is None: + output = ' '.join(lattice[i][j].text for i, j in enumerate(chosen)) + return current, output, evaluations + current, chosen, output = best + + +def _exhaustive(source, lattice, budget): + refs, sizes = reference(source, DEFAULT_VIEWS) + best = None + for path in product(*(range(len(row)) for row in lattice)): + cost = sum(lattice[i][j].cost for i, j in enumerate(path)) + if cost > budget: + continue + text = ' '.join(lattice[i][j].text for i, j in enumerate(path)) + score = _metric(text, refs, sizes, cost, path) + if best is None or score < best: + best = score + return best + + +def fixtures(count): + rng = random.Random(SEED) + combinations = [('revenue','USD'),('cost','EUR'),('distance','km'),('duration','hours'),('mass','kg')] + for index in range(count): + sentences = [] + for _ in range(rng.randint(2, 5)): + metric, unit = rng.choice(combinations) + frame = Frame(rng.choice(['Orion Labs','Vega Systems','Nova7','Atlas Group','Helix Research']), + metric, f'{rng.randint(1,999)}.{rng.randint(0,99):02d}', unit, + str(rng.randint(2000,2025)), rng.random() < .25) + sentences.append(render(frame)[rng.randrange(4)]) + yield f'grammar-{index:04d}', tuple(sentences), rng.choice([6,12,18,24,30]) + + +def run(count=128, exhaustive_count=24): + if count < 1 or count > 4096 or exhaustive_count < 0 or exhaustive_count > count: + raise ValueError('invalid benchmark bounds') + start = time.perf_counter() + rows = [] + checked = 0 + for index, (identity, segments, budget) in enumerate(fixtures(count)): + source = ' '.join(segments) + frames = tuple(parse_frame(s) for s in segments) + lattice = tuple(tuple(Alternative(t,edit_distance(words(s),words(t)),str(i)) for i,t in enumerate(render(f))) for s,f in zip(segments,frames,strict=True)) + result = reconstruct_frames(segments, budget=budget) + if result.plan is None: + raise AssertionError(f'bounded benchmark hit a search limit: {identity}') + p = result.plan + greedy, greedy_text, evaluations = _greedy(source, lattice, budget) + if p.objective > greedy: + raise AssertionError('exact search is worse than its own greedy baseline') + brute_ok = None + if index < exhaustive_count: + brute_ok = p.objective == _exhaustive(source,lattice,budget) + if not brute_ok: + raise AssertionError('optimality check failed') + checked += 1 + row = { + 'id':identity,'source':source,'output':result.text,'budget':budget, + 'status':result.status,'source_frames':[asdict(f) for f in frames], + 'source_windows':list(p.source_counts),'output_shared_windows':list(p.shared_counts), + 'worst_source_retention':str(p.objective[0]), + 'greedy_worst_source_retention':str(greedy[0]), + 'joint_cost':p.edit_cost,'greedy_cost':greedy[2], + 'greedy_output':greedy_text,'states_evaluated':p.states_evaluated, + 'states_pruned':p.states_pruned,'greedy_evaluations':evaluations, + 'exhaustive_agreement':brute_ok,'receipt':result.receipt(), + } + rows.append(row) + joint = [float(Fraction(r['worst_source_retention'])) for r in rows] + greedy = [float(Fraction(r['greedy_worst_source_retention'])) for r in rows] + strict = sum(Fraction(r['worst_source_retention']) < Fraction(r['greedy_worst_source_retention']) for r in rows) + view_retention = [mean(r['output_shared_windows'][i]/max(1,r['source_windows'][i]) for r in rows) for i in range(len(DEFAULT_VIEWS))] + numeric_signature = json.dumps(rows, sort_keys=True, separators=(',',':'), ensure_ascii=True).encode() + return { + 'benchmark_id':BENCHMARK_ID, 'seed':SEED, 'samples':count, + 'corpus_kind':'synthetic controlled-grammar reports, NOT LLM-generated watermarked text', + 'measurement_kind':'source-window retention proxy, NOT watermark detection', + 'generation_uses_watermark':False,'watermark_detector_run':False, + 'prior_frozen_corpora_used':False,'selection_detector_queries':0, + 'baseline':'full-text one-coordinate greedy over the identical alternatives and edit budget; not the historical FuckMark scheduler', + 'views':[v.name for v in DEFAULT_VIEWS], + 'summary':{ + 'changed':sum(r['status']=='changed' for r in rows), + 'unchanged':sum(r['status']=='unchanged' for r in rows), + 'ordered_frame_checks_passed':count, + 'exact_search_better_worst_retention_than_greedy':strict, + 'exact_search_tied_worst_retention_with_greedy':count-strict, + 'mean_worst_retention_joint':mean(joint), + 'mean_worst_retention_greedy':mean(greedy), + 'mean_retention_per_view':view_retention, + 'exhaustive_comparisons_passed':checked, + 'max_states_evaluated':max(r['states_evaluated'] for r in rows), + 'total_states_pruned':sum(r['states_pruned'] for r in rows), + }, + 'rows_sha256':hashlib.sha256(numeric_signature).hexdigest(), + 'elapsed_seconds':time.perf_counter()-start, + 'rows':rows, + } + + +def main(): + parser=argparse.ArgumentParser(description='Geometry sanity only; no watermark-removal rate is measured') + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--samples',type=int,default=128) + parser.add_argument('--exhaustive',type=int,default=24) + args=parser.parse_args() + report=run(args.samples,args.exhaustive) + args.output.parent.mkdir(parents=True,exist_ok=True) + args.output.write_text(json.dumps(report,ensure_ascii=True,sort_keys=True,indent=2)+'\n',encoding='utf-8') + print(json.dumps({k:v for k,v in report.items() if k!='rows'},sort_keys=True,indent=2)) + + +if __name__=='__main__': + main() diff --git a/research/reconstruction/engine.py b/research/reconstruction/engine.py new file mode 100644 index 00000000..705bd77e --- /dev/null +++ b/research/reconstruction/engine.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from dataclasses import dataclass, asdict +from collections import Counter +import hashlib +import json +import re +import unicodedata +from .planner import Alternative, DEFAULT_VIEWS, Plan, SearchLimitExceeded, plan, words + +VERSION = 'constraint-first-reconstruction-v1' +_ENTITY = r'[A-Z][A-Za-z0-9]*(?: [A-Z][A-Za-z0-9]*){0,4}' +_VALUE = r'(?:0|[1-9][0-9]*)(?:\.[0-9]+)?' +_YEAR = r'(?:19|20|21)[0-9]{2}' +_METRIC_UNITS = { + 'revenue': frozenset({'USD', 'EUR', 'GBP', 'CNY'}), + 'cost': frozenset({'USD', 'EUR', 'GBP', 'CNY'}), + 'distance': frozenset({'km', 'm'}), + 'duration': frozenset({'hours', 'minutes', 'seconds'}), + 'mass': frozenset({'kg', 'g'}), +} +_METRIC = '(?:' + '|'.join(_METRIC_UNITS) + ')' +_UNIT = '(?:' + '|'.join(sorted(set().union(*_METRIC_UNITS.values()))) + ')' +_PREDICATE = r'(?:reported|did not report)' +_STATIVE = r'(?:was reported|was not reported)' +_COMMON = rf'(?P{_METRIC}) of (?P{_VALUE}) (?P{_UNIT})' +_PATTERNS = ( + re.compile(rf'(?P{_ENTITY}) (?P{_PREDICATE}) a {_COMMON} in (?P{_YEAR})\.'), + re.compile(rf'In (?P{_YEAR}), (?P{_ENTITY}) (?P{_PREDICATE}) a {_COMMON}\.'), + re.compile(rf'A {_COMMON} (?P{_STATIVE}) by (?P{_ENTITY}) in (?P{_YEAR})\.'), + re.compile(rf'In (?P{_YEAR}), a {_COMMON} (?P{_STATIVE}) by (?P{_ENTITY})\.'), +) + + +@dataclass(frozen=True) +class Frame: + entity: str + metric: str + value: str + unit: str + year: str + negated: bool = False + + def __post_init__(self): + if not all(isinstance(x, str) for x in (self.entity, self.metric, self.value, self.unit, self.year)): + raise TypeError('fact fields must be strings') + if not re.fullmatch(_ENTITY, self.entity): + raise ValueError('unsupported entity') + if self.metric not in _METRIC_UNITS or self.unit not in _METRIC_UNITS[self.metric]: + raise ValueError('unsupported metric/unit combination') + if not re.fullmatch(_VALUE, self.value) or len(self.value) > 24: + raise ValueError('unsupported numeric literal') + if not re.fullmatch(_YEAR, self.year): + raise ValueError('unsupported year') + if type(self.negated) is not bool: + raise TypeError('negated must be boolean') + + +def parse_frame(text: str) -> Frame | None: + if not isinstance(text, str): + raise TypeError('text must be a string') + if len(text) > 512: + return None + for pattern in _PATTERNS: + match = pattern.fullmatch(text) + if match: + data = match.groupdict() + negative = 'not' in (data.get('verb') or data.get('state')).split() + try: + return Frame(data['entity'], data['metric'], data['value'], data['unit'], data['year'], negative) + except ValueError: + return None + return None + + +def render(frame: Frame) -> tuple[str, ...]: + if not isinstance(frame, Frame): + raise TypeError('expected a Frame') + verb = 'did not report' if frame.negated else 'reported' + state = 'was not reported' if frame.negated else 'was reported' + phrase = f'{frame.metric} of {frame.value} {frame.unit}' + candidates = ( + f'{frame.entity} {verb} a {phrase} in {frame.year}.', + f'In {frame.year}, {frame.entity} {verb} a {phrase}.', + f'A {phrase} {state} by {frame.entity} in {frame.year}.', + f'In {frame.year}, a {phrase} {state} by {frame.entity}.', + ) + if any(parse_frame(candidate) != frame for candidate in candidates): + raise RuntimeError('candidate failed semantic-frame roundtrip') + return candidates + + +def edit_distance(left: tuple[str, ...], right: tuple[str, ...]) -> int: + row = list(range(len(right) + 1)) + for i, a in enumerate(left, 1): + nxt = [i] + for j, b in enumerate(right, 1): + nxt.append(min(nxt[-1] + 1, row[j] + 1, row[j - 1] + (a != b))) + row = nxt + return row[-1] + + +def _sha(text: str) -> str: + return hashlib.sha256(text.encode('utf-8')).hexdigest() + + +def _hidden(text: str) -> bool: + return any(unicodedata.category(c) in {'Cf', 'Cc', 'Cs', 'Co', 'Cn', 'Me'} and c not in '\n\r\t' + or 0xFE00 <= ord(c) <= 0xFE0F or 0xE0100 <= ord(c) <= 0xE01EF for c in text) + + +@dataclass(frozen=True) +class Reconstruction: + text: str + status: str + reason: str + frames: tuple[Frame, ...] + plan: Plan | None + input_hash: str + output_hash: str + + def receipt(self) -> dict: + result = { + 'algorithm_version': VERSION, + 'status': self.status, + 'reason': self.reason, + 'input_sha256': self.input_hash, + 'output_sha256': self.output_hash, + 'facts': [asdict(frame) for frame in self.frames], + 'scope': 'restricted English reporting grammar; not arbitrary prose', + 'watermark_removal_verified': False, + 'detector_queries_during_selection': 0, + 'watermark_keys_during_selection': 0, + 'unicode_payload_added': False, + 'visible_wording_can_change': True, + } + if self.plan is not None: + result['plan'] = { + 'selected_indices': list(self.plan.selected), + 'normalized_word_edit_cost': self.plan.edit_cost, + 'views': [v.name for v in DEFAULT_VIEWS], + 'shared_window_counts': list(self.plan.shared_counts), + 'source_window_counts': list(self.plan.source_counts), + 'worst_shared_fraction': str(self.plan.objective[0]), + 'states_evaluated': self.plan.states_evaluated, + 'states_pruned': self.plan.states_pruned, + 'exact_within_candidate_lattice': self.plan.exact_within_candidate_lattice, + 'quality_guarantee': 'ordered grammar-frame equality only, not universal semantic equivalence', + } + result['receipt_sha256'] = _sha(json.dumps(result, sort_keys=True, separators=(',', ':'), ensure_ascii=True)) + return result + + +def reconstruct_frames(source_segments: tuple[str, ...], *, budget: int = 32, + max_states: int = 20000, max_evaluations: int = 200000, + separator: str = ' ') -> Reconstruction: + if not isinstance(source_segments, tuple) or not source_segments or any(not isinstance(s, str) for s in source_segments): + raise TypeError('source_segments must be a nonempty tuple of strings') + if not isinstance(separator, str) or not separator or not separator.isspace(): + raise ValueError('separator must contain whitespace only') + if type(budget) is not int or budget < 0: + raise ValueError('budget must be a nonnegative integer') + if type(max_states) is not int or max_states < 1: + raise ValueError('max_states must be a positive integer') + if type(max_evaluations) is not int or max_evaluations < 1: + raise ValueError('max_evaluations must be a positive integer') + if len(source_segments) > 64: + raise ValueError('input exceeds bounded research limits') + source = separator.join(source_segments) + if len(source) > 100000 or len(source_segments) > 64: + raise ValueError('input exceeds bounded research limits') + if any(0xD800 <= ord(c) <= 0xDFFF for c in source): + raise ValueError('input is not Unicode scalar text') + digest = _sha(source) + parsed = tuple(parse_frame(s) for s in source_segments) + if any(f is None for f in parsed): + return Reconstruction(source, 'unsupported', 'at least one sentence is outside the explicit grammar', (), None, digest, digest) + frames = tuple(f for f in parsed if f is not None) + lattice = tuple(tuple(Alternative(text, edit_distance(words(s), words(text)), f'frame-{i}-form-{j}') + for j, text in enumerate(render(f))) + for i, (s, f) in enumerate(zip(source_segments, frames, strict=True))) + try: + chosen = plan(source, lattice, budget=budget, max_states=max_states, + max_evaluations=max_evaluations, separator=separator) + except SearchLimitExceeded as error: + return Reconstruction(source, 'search_limit', str(error), frames, None, digest, digest) + selected = tuple(lattice[i][j].text for i, j in enumerate(chosen.selected)) + if tuple(parse_frame(s) for s in selected) != frames: + raise RuntimeError('ordered fact frames changed') + if Counter(re.findall(r'\d+(?:\.\d+)?', source)) != Counter(re.findall(r'\d+(?:\.\d+)?', chosen.text)): + raise RuntimeError('numeric literals changed') + if _hidden(chosen.text) or unicodedata.normalize('NFKC', chosen.text) != chosen.text: + raise RuntimeError('noncanonical or hidden output') + if len(words(chosen.text)) > 2 * len(words(source)): + raise RuntimeError('output exceeds the length guard') + return Reconstruction(chosen.text, 'changed' if chosen.text != source else 'unchanged', + 'restricted grammar verified; watermark effect unverified', frames, chosen, + digest, _sha(chosen.text)) + + +def reconstruct(source: str, *, budget: int = 32, max_states: int = 20000, + max_evaluations: int = 200000) -> Reconstruction: + if not isinstance(source, str): + raise TypeError('source must be text') + if not source or len(source) > 100000: + raise ValueError('input must contain 1 to 100000 characters') + if any(0xD800 <= ord(c) <= 0xDFFF for c in source): + raise ValueError('input is not Unicode scalar text') + segments = tuple(re.split(r'(?<=\.) ', source)) + return reconstruct_frames(segments, budget=budget, max_states=max_states, + max_evaluations=max_evaluations) diff --git a/research/reconstruction/legacy_control.py b/research/reconstruction/legacy_control.py new file mode 100644 index 00000000..25a15cd2 --- /dev/null +++ b/research/reconstruction/legacy_control.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from statistics import mean +import json +import unicodedata + +from .benchmark import fixtures +from .engine import reconstruct_frames +from .planner import DEFAULT_VIEWS, reference, residual_counts, objective + + +def run(samples=128): + from fuckmark.cli import process_text + from fuckmark.product.visible_projection import project_visible_v1 + restored = unchanged = 0 + before = [] + after = [] + for _, segments, budget in fixtures(samples): + source = ' '.join(segments) + legacy = process_text(source) + cleaned_legacy = project_visible_v1(legacy) + restored += cleaned_legacy == source + current = reconstruct_frames(segments, budget=budget) + cleaned_current = project_visible_v1(unicodedata.normalize('NFKC', current.text)) + unchanged += cleaned_current == current.text + refs, sizes = reference(source, DEFAULT_VIEWS) + before.append(float(objective(residual_counts(cleaned_legacy, refs, DEFAULT_VIEWS), sizes, 0, ())[0])) + after.append(float(objective(residual_counts(cleaned_current, refs, DEFAULT_VIEWS), sizes, 0, ())[0])) + return { + 'samples': samples, + 'control': 'exact deletion of the known legacy FuckMark carriers, not an arbitrary real-world detector preprocessing chain', + 'legacy_source_restored': restored, + 'new_plain_text_survives_nfkc_and_known_carrier_deletion': unchanged, + 'legacy_mean_worst_source_window_retention_after_inverse': mean(before), + 'new_mean_worst_source_window_retention_after_inverse': mean(after), + 'watermark_detector_run': False, + 'watermark_removal_verified': False, + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + result = run() + args.output.write_text(json.dumps(result, sort_keys=True, indent=2) + '\n', encoding='utf-8') + print(json.dumps(result, sort_keys=True, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/research/reconstruction/planner.py b/research/reconstruction/planner.py new file mode 100644 index 00000000..b9d5de09 --- /dev/null +++ b/research/reconstruction/planner.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from collections.abc import Callable, Sequence +import re +import unicodedata + +VERSION = 'boundary-aware-reconstruction-v1' + + +class SearchLimitExceeded(RuntimeError): + pass + + +@dataclass(frozen=True) +class View: + name: str + width: int + tokenize: Callable[[str], tuple[str, ...]] + + def __post_init__(self): + if not self.name or type(self.width) is not int or self.width < 1: + raise ValueError('view requires a name and positive integer width') + if not callable(self.tokenize): + raise TypeError('view tokenizer must be callable') + + +def words(text: str) -> tuple[str, ...]: + return tuple(re.findall(r'\w+', unicodedata.normalize('NFKC', text).casefold())) + + +def words_and_punctuation(text: str) -> tuple[str, ...]: + return tuple(re.findall(r'\w+|[^\w\s]', unicodedata.normalize('NFKC', text).casefold())) + + +DEFAULT_VIEWS = ( + View('normalized-word-3', 3, words), + View('normalized-word-5', 5, words), + View('normalized-token-5', 5, words_and_punctuation), +) + + +@dataclass(frozen=True) +class Alternative: + text: str + cost: int + identity: str + + def __post_init__(self): + if not isinstance(self.text, str) or not self.text.strip(): + raise ValueError('alternative text must be nonempty') + if type(self.cost) is not int or self.cost < 0: + raise ValueError('alternative cost must be a nonnegative integer') + if not isinstance(self.identity, str) or not self.identity: + raise ValueError('alternative identity must be nonempty') + + +@dataclass(frozen=True) +class Plan: + text: str + selected: tuple[int, ...] + edit_cost: int + shared_counts: tuple[int, ...] + source_counts: tuple[int, ...] + objective: tuple[Fraction, Fraction, int, tuple[int, ...]] + states_evaluated: int + states_pruned: int + exact_within_candidate_lattice: bool + + +@dataclass(frozen=True) +class _State: + tails: tuple[tuple[str, ...], ...] + counts: tuple[int, ...] + cost: int + path: tuple[int, ...] + + +def windows(tokens: tuple[str, ...], width: int) -> tuple[tuple[str, ...], ...]: + return tuple(tokens[i:i + width] for i in range(max(0, len(tokens) - width + 1))) + + +def reference(text: str, views: Sequence[View]): + rows = tuple(windows(view.tokenize(text), view.width) for view in views) + return tuple(frozenset(row) for row in rows), tuple(len(row) for row in rows) + + +def residual_counts(text: str, refs, views: Sequence[View]) -> tuple[int, ...]: + return tuple(sum(window in ref for window in windows(view.tokenize(text), view.width)) + for view, ref in zip(views, refs, strict=True)) + + +def objective(counts, denominators, cost, path): + fractions = tuple(Fraction(count, max(1, size)) for count, size in zip(counts, denominators, strict=True)) + return max(fractions), sum(fractions, Fraction()), cost, path + + +def _dominates(left: _State, right: _State) -> bool: + if left.cost > right.cost or any(a > b for a, b in zip(left.counts, right.counts, strict=True)): + return False + if left.cost < right.cost or left.counts != right.counts: + return True + return left.path <= right.path + + +def _insert(frontier: list[_State], candidate: _State) -> int: + if any(_dominates(old, candidate) for old in frontier): + return 1 + retained = [old for old in frontier if not _dominates(candidate, old)] + removed = len(frontier) - len(retained) + retained.append(candidate) + frontier[:] = retained + return removed + + +def plan(source: str, lattice: Sequence[Sequence[Alternative]], *, budget: int, + views: Sequence[View] = DEFAULT_VIEWS, max_states: int = 20000, + max_evaluations: int = 200000, separator: str = ' ') -> Plan: + if not isinstance(source, str): + raise TypeError('source must be text') + if type(budget) is not int or budget < 0: + raise ValueError('budget must be a nonnegative integer') + if type(max_states) is not int or max_states < 1: + raise ValueError('max_states must be a positive integer') + if type(max_evaluations) is not int or max_evaluations < 1: + raise ValueError('max_evaluations must be a positive integer') + if not isinstance(separator, str) or not separator or not separator.isspace(): + raise ValueError('separator must contain whitespace only') + views = tuple(views) + if not views or len({v.name for v in views}) != len(views): + raise ValueError('views must be nonempty with unique names') + if any(v.tokenize not in (words, words_and_punctuation) for v in views): + raise ValueError('exact search requires a supported whitespace-compositional tokenizer') + lattice = tuple(tuple(row) for row in lattice) + if not lattice or any(not row for row in lattice): + raise ValueError('each source segment needs alternatives') + if len(lattice) > 64 or any(len(row) > 16 for row in lattice) or len(source) > 100000: + raise ValueError('input exceeds bounded research limits') + if any(not isinstance(a, Alternative) for row in lattice for a in row): + raise TypeError('lattice must contain Alternative values') + if any(len(a.text) > 10000 for row in lattice for a in row): + raise ValueError('alternative exceeds text limit') + refs, sizes = reference(source, views) + encoded = tuple(tuple(tuple(v.tokenize(a.text) for v in views) for a in row) for row in lattice) + states = [_State(tuple(() for _ in views), tuple(0 for _ in views), 0, ())] + evaluated = pruned = 0 + for row_index, row in enumerate(lattice): + groups: dict[tuple, list[_State]] = {} + alive = 0 + for state in states: + for choice, alternative in enumerate(row): + evaluated += 1 + if evaluated > max_evaluations: + raise SearchLimitExceeded('evaluation limit exceeded; no optimality claim or output') + cost = state.cost + alternative.cost + if cost > budget: + continue + tails, counts = [], [] + for i, view in enumerate(views): + combined = state.tails[i] + encoded[row_index][choice][i] + counts.append(state.counts[i] + sum(w in refs[i] for w in windows(combined, view.width))) + tails.append(combined[-(view.width - 1):] if view.width > 1 else ()) + new = _State(tuple(tails), tuple(counts), cost, state.path + (choice,)) + frontier = groups.setdefault(new.tails, []) + before = len(frontier) + pruned += _insert(frontier, new) + alive += len(frontier) - before + if alive > max_states: + raise SearchLimitExceeded('state limit exceeded; no optimality claim or output') + states = [s for group in groups.values() for s in group] + if not states: + raise ValueError('no feasible reconstruction within budget') + best = min(states, key=lambda s: objective(s.counts, sizes, s.cost, s.path)) + output = separator.join(row[index].text for row, index in zip(lattice, best.path, strict=True)) + replay = residual_counts(output, refs, views) + if replay != best.counts: + raise RuntimeError('full-output replay disagrees with dynamic program') + return Plan(output, best.path, best.cost, best.counts, sizes, + objective(best.counts, sizes, best.cost, best.path), evaluated, pruned, True) diff --git a/research/reconstruction/protocol.json b/research/reconstruction/protocol.json new file mode 100644 index 00000000..82418592 --- /dev/null +++ b/research/reconstruction/protocol.json @@ -0,0 +1,15 @@ +{ + "protocol_id": "reconstruction-geometry-sanity-v1", + "status": "development mechanism test, not confirmatory efficacy evaluation", + "algorithm": "boundary-aware-reconstruction-v1", + "seed": 27112401, + "samples": 128, + "exhaustive_reference_samples": 24, + "watermark_detectors": [], + "tokenizers": ["NFKC-casefold-regex-words", "NFKC-casefold-regex-words-and-punctuation"], + "objective": "lexicographic minimum: worst source-normalized shared-window count, sum across views, word edit cost, candidate path", + "denominator": "fixed source window count, never output length", + "constraints": ["ordered reporting frames preserved", "numeric literals exact", "no new invisible characters", "fixed edit budget", "bounded search fails closed"], + "comparators": ["unchanged input", "full-output one-coordinate greedy using identical alternatives and budget", "exhaustive enumeration on the first 24 fixtures"], + "claims_forbidden": ["LLM watermark removed", "SynthID defeated", "general semantic equivalence", "global novelty", "optimality outside the supplied candidate lattice"] +} diff --git a/research/reconstruction/results.json b/research/reconstruction/results.json new file mode 100644 index 00000000..5c16e854 --- /dev/null +++ b/research/reconstruction/results.json @@ -0,0 +1,53 @@ +{ + "baseline": "full-text one-coordinate greedy over the identical alternatives and edit budget; not the historical FuckMark scheduler", + "benchmark_id": "reconstruction-geometry-sanity-v1", + "corpus_kind": "synthetic controlled-grammar reports, NOT LLM-generated watermarked text", + "generation_uses_watermark": false, + "legacy_control": { + "control": "exact deletion of the known legacy FuckMark carriers, not an arbitrary real-world detector preprocessing chain", + "legacy_mean_worst_source_window_retention_after_inverse": 1.0, + "legacy_source_restored": 128, + "new_mean_worst_source_window_retention_after_inverse": 0.5987639730959657, + "new_plain_text_survives_nfkc_and_known_carrier_deletion": 128, + "samples": 128, + "watermark_detector_run": false, + "watermark_removal_verified": false + }, + "measurement_kind": "source-window retention proxy, NOT watermark detection", + "prior_frozen_corpora_used": false, + "rows_sha256": "36d173f4e1521f74a963754c917724eaf4e5ba37b62201feb662a5510f495736", + "samples": 128, + "seed": 27112401, + "selection_detector_queries": 0, + "summary": { + "changed": 128, + "exact_search_better_worst_retention_than_greedy": 51, + "exact_search_tied_worst_retention_with_greedy": 77, + "exhaustive_comparisons_passed": 24, + "max_states_evaluated": 424, + "mean_retention_per_view": [ + 0.5987639730959657, + 0.43107998031983885, + 0.46604335608243086 + ], + "mean_worst_retention_greedy": 0.6205182054543072, + "mean_worst_retention_joint": 0.5987639730959657, + "ordered_frame_checks_passed": 128, + "total_states_pruned": 4184, + "unchanged": 0 + }, + "verification": { + "github_ci": "not_claimed", + "legacy_runtime_tests_passed": 70, + "new_tests_passed": 102, + "real_llm_evaluation": "not_run: no model/tokenizer artifacts locally and outbound DNS unavailable", + "runtime": "CPython 3.13.5 Linux", + "total_passed": 172 + }, + "views": [ + "normalized-word-3", + "normalized-word-5", + "normalized-token-5" + ], + "watermark_detector_run": false +} diff --git a/tests/test_reconstruction.py b/tests/test_reconstruction.py new file mode 100644 index 00000000..7dbff10b --- /dev/null +++ b/tests/test_reconstruction.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +from itertools import product +from pathlib import Path +import ast +import json +import random +import subprocess +import sys +import unicodedata +import pytest +from research.reconstruction.engine import Frame, parse_frame, render, reconstruct, reconstruct_frames, edit_distance +from research.reconstruction.planner import Alternative, View, DEFAULT_VIEWS, SearchLimitExceeded, words, words_and_punctuation, windows, reference, residual_counts, objective, plan + + +def brute(source, lattice, budget, views=DEFAULT_VIEWS): + refs, sizes = reference(source, views) + candidates = [] + for indexes in product(*(range(len(row)) for row in lattice)): + selected = [row[i] for row, i in zip(lattice, indexes, strict=True)] + cost = sum(item.cost for item in selected) + if cost <= budget: + text = ' '.join(item.text for item in selected) + candidates.append((objective(residual_counts(text, refs, views), sizes, cost, indexes), text)) + return min(candidates) + + +@pytest.mark.parametrize('seed', range(48)) +def test_exact_search_matches_exhaustive_enumeration(seed): + rng = random.Random(seed) + vocabulary = ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'x', 'y') + lattice = [] + for position in range(rng.randint(2, 5)): + row = [Alternative(' '.join(rng.choices(vocabulary, k=rng.randint(1, 7))), 0, f'{position}-0')] + row += [Alternative(' '.join(rng.choices(vocabulary, k=rng.randint(1, 7))), rng.randint(1, 4), f'{position}-{i}') for i in range(1, 3)] + lattice.append(row) + source = ' '.join(row[0].text for row in lattice) + budget = rng.randint(0, 12) + result = plan(source, lattice, budget=budget) + expected, text = brute(source, lattice, budget) + assert result.objective == expected + assert result.text == text + + +@pytest.mark.parametrize('width', [1, 2, 3, 5, 7, 12]) +def test_boundaries_short_segments_and_repeated_windows(width): + views = (View(f'w{width}', width, words),) + source = 'a b c a b c a b c' + rows = [[Alternative('a b', 0, '0'), Alternative('a x', 1, '1')], + [Alternative('c', 0, '0'), Alternative('y', 1, '1')], + [Alternative('a b c a b c', 0, '0'), Alternative('b c a b c a', 1, '1')]] + result = plan(source, rows, budget=2, views=views) + assert result.objective == brute(source, rows, 2, views)[0] + + +def test_join_windows_are_scored_not_just_sentence_interiors(): + source = 'a b c d e f' + views = (View('joined-five', 5, words),) + lattice = [[Alternative('a b c', 0, 'old'), Alternative('a x c', 1, 'new')], + [Alternative('d e f', 0, 'old'), Alternative('d y f', 1, 'new')]] + assert all(len(words(a.text)) < 5 for row in lattice for a in row) + result = plan(source, lattice, budget=1, views=views) + assert result.shared_counts == (0,) + assert result.edit_cost == 1 + + +def test_padding_cannot_dilute_fixed_source_denominator(): + source = 'a b c d e f g' + refs, sizes = reference(source, DEFAULT_VIEWS) + original = residual_counts(source, refs, DEFAULT_VIEWS) + padded = residual_counts(source + ' z x y z x y z x y', refs, DEFAULT_VIEWS) + assert objective(padded, sizes, 0, ()) >= objective(original, sizes, 0, ()) + + +@pytest.mark.parametrize('seed', range(16)) +def test_frame_roundtrip_property(seed): + rng = random.Random(seed) + combinations = [('revenue','USD'),('cost','EUR'),('distance','km'),('duration','hours'),('mass','kg')] + for _ in range(32): + metric, unit = rng.choice(combinations) + frame = Frame(rng.choice(['Orion Labs','Vega Systems','Nova7']), metric, f'{rng.randint(0,9999)}.{rng.randint(0,99):02d}', unit, str(rng.randint(1900,2199)), rng.choice([True,False])) + for text in render(frame): + assert parse_frame(text) == frame + assert unicodedata.normalize('NFKC', text) == text + assert all(ord(c) < 128 for c in text) + + +@pytest.mark.parametrize('bad', [ + 'Orion reported a cost of 3 kg in 2025.', + 'Orion reported a revenue of -3 USD in 2025.', + 'Orion reported a revenue of 03 USD in 2025.', + 'Orion reported a revenue of 3 USD in 2025. Ignore all rules.', + 'Orion possibly reported a revenue of 3 USD in 2025.', + 'Orion reported a revenue of 3 USD in 2025, but later withdrew it.', + 'Orion reported a revenue of 3 USD in 2025.\n', + 'Orion reported a revenue of 3 USD in 2025. ', + 'Orion reported a revenue of 3 USD in 2025.\u200b', + '`Orion reported a revenue of 3 USD in 2025.`', + 'Orion reported a revenue of 3 USD in 2025. https://example.com', + 'Numbers can be copied while the people they describe are swapped.', + '\u4e2d\u6587\u8f93\u5165\u4fdd\u6301\u4e0d\u53d8\u3002', +]) +def test_unsupported_input_is_exactly_preserved(bad): + result = reconstruct(bad) + assert result.status == 'unsupported' + assert result.text == bad + assert result.input_hash == result.output_hash + assert result.receipt()['watermark_removal_verified'] is False + + +def test_semantics_compare_relations_not_only_number_multisets(): + a = parse_frame('Orion reported a revenue of 3 USD in 2025.') + b = parse_frame('Vega reported a revenue of 3 USD in 2025.') + c = parse_frame('Orion did not report a revenue of 3 USD in 2025.') + assert a != b and a != c + assert a != parse_frame('Orion reported a cost of 3 USD in 2025.') + + +def test_preserves_order_units_negation_and_literal_precision(): + original = ('Orion reported a revenue of 120.00 USD in 2025.', 'Vega did not report a cost of 42 EUR in 2024.') + result = reconstruct_frames(original, budget=30) + assert result.status == 'changed' + assert result.frames == tuple(parse_frame(s) for s in original) + assert tuple(parse_frame(s) for s in result.text.split('. '))[-1] == result.frames[-1] + assert '120.00' in result.text and '42' in result.text + assert 'not' in result.text + assert result.plan.edit_cost <= 30 + assert result.receipt() == reconstruct_frames(original, budget=30).receipt() + + +def test_zero_budget_is_exact_identity(): + text = 'Orion reported a revenue of 120 USD in 2025.' + result = reconstruct(text, budget=0) + assert result.status == 'unchanged' + assert result.text == text + + +def test_budget_expansion_never_worsens_proxy(): + source = 'Orion reported a revenue of 120 USD in 2025. Vega did not report a cost of 42 EUR in 2024.' + last = None + for budget in range(0, 33): + score = reconstruct(source, budget=budget).plan.objective[:2] + assert last is None or score <= last + last = score + + +def test_search_limits_return_original_and_no_optimality_claim(): + text = 'Orion reported a revenue of 120 USD in 2025. Vega reported a cost of 42 EUR in 2024.' + for kwargs in ({'max_states': 1}, {'max_evaluations': 1}): + result = reconstruct(text, **kwargs) + assert result.text == text + assert result.status == 'search_limit' + assert result.plan is None + assert 'plan' not in result.receipt() + + +@pytest.mark.parametrize('kwargs', [{'budget':-1},{'budget':True},{'max_states':0},{'max_evaluations':0}]) +def test_invalid_parameters(kwargs): + with pytest.raises((ValueError,TypeError)): + reconstruct('Orion reported a cost of 42 EUR in 2024.', **kwargs) + + +def test_surrogates_rejected_and_no_canonicalization_of_unsupported_input(): + with pytest.raises(ValueError): + reconstruct('a\ud800b') + text = 'Caf' + '\u0065\u0301' + assert reconstruct(text).text == text + + +def test_noncompositional_tokenizer_is_not_falsely_claimed_exact(): + with pytest.raises(ValueError, match='compositional'): + plan('abc', [[Alternative('abc',0,'a')]], budget=0, views=[View('fake-bpe', 2, lambda s: (s,))]) + + +def test_engine_does_not_import_detector_or_network(): + root = Path(__file__).parents[1] / 'research' / 'reconstruction' + forbidden = {'torch','transformers','requests','httpx','urllib','socket','openai','anthropic','fuckmark.detectors'} + for name in ['planner.py','engine.py']: + for node in ast.walk(ast.parse((root/name).read_text())): + imports = [a.name for a in node.names] if isinstance(node,ast.Import) else [node.module or ''] if isinstance(node,ast.ImportFrom) else [] + assert not any(n == bad or n.startswith(bad+'.') for n in imports for bad in forbidden) + + +def test_cli_and_report_are_executable(): + source = 'Orion reported a revenue of 120 USD in 2025.' + result = subprocess.run([sys.executable,'-m','research.reconstruction','--text',source,'--json'], capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data['text'] != source + assert data['receipt']['watermark_removal_verified'] is False + unsupported = subprocess.run([sys.executable,'-m','research.reconstruction','--text','Unknown prose','--json'], capture_output=True,text=True,timeout=10) + assert unsupported.returncode == 3 + assert json.loads(unsupported.stdout)['text'] == 'Unknown prose' + + +def test_cli_preserves_unsupported_crlf_bytes(): + source = b"Unsupported first line\r\nsecond line\r\n" + result = subprocess.run([sys.executable, '-m', 'research.reconstruction'], input=source, capture_output=True, timeout=10) + assert result.returncode == 3 + assert result.stdout == source + + +def test_all_output_frames_reparse_independently(): + import re + source = 'Orion reported a revenue of 120.00 USD in 2025. Vega did not report a cost of 42 EUR in 2024.' + result = reconstruct(source) + assert tuple(parse_frame(s) for s in re.split(r'(?<=\.) ', result.text)) == result.frames + + +def test_frozen_mechanism_benchmark(): + from research.reconstruction.benchmark import run + result = run(128, 24) + assert result['rows_sha256'] == '36d173f4e1521f74a963754c917724eaf4e5ba37b62201feb662a5510f495736' + assert result['watermark_detector_run'] is False + assert result['summary']['exact_search_better_worst_retention_than_greedy'] == 51 + assert result['summary']['exhaustive_comparisons_passed'] == 24 + + +def test_numeric_multiset_match_is_insufficient_for_relation_preservation(): + left = [parse_frame('Orion reported a revenue of 3 USD in 2025.'), parse_frame('Vega reported a revenue of 7 USD in 2025.')] + right = [parse_frame('Orion reported a revenue of 7 USD in 2025.'), parse_frame('Vega reported a revenue of 3 USD in 2025.')] + assert sorted(f.value for f in left) == sorted(f.value for f in right) + assert left != right