Spaces:
Running
Running
Download src/trace2env/reconstruction.py from Quanyu001/trace2env_demo: direct link, hf CLI and curl.
- Browser
- Download file 51 kB
-
https://huggingface.co/spaces/Quanyu001/trace2env_demo/resolve/main/src/trace2env/reconstruction.py
- Command line
-
hf download hf://spaces/Quanyu001/trace2env_demo/src/trace2env/reconstruction.py
-
curl -L -o reconstruction.py https://huggingface.co/spaces/Quanyu001/trace2env_demo/resolve/main/src/trace2env/reconstruction.py
51 kB
| """Offline evidence-to-package reconstruction pipeline.""" | |
| from __future__ import annotations | |
| import json | |
| import hashlib | |
| import re | |
| from pathlib import Path | |
| from typing import Any, Iterable | |
| from trace2env.adapters import annotate_episode, events_for_slice, load_raw_traces, segment_transitions | |
| from trace2env.induction import BoundedInduction | |
| from trace2env.validation import (action_aliases, canonical_state_path, invalid_render_references, renderer_issues, repair_provenance, | |
| rule_has_observed_support, rule_issues, state_field, state_path_aliases, | |
| validate_source_ref, value_type) | |
| from trace2env.engine import set_path | |
| from trace2env.contrasts import build_contrasts | |
| from trace2env.eligibility import construction_scope | |
| from trace2env.compiler import EnvironmentCompiler | |
| from trace2env.llm import ProviderRefusal, StructuredLLM, StructuredOutputError | |
| from trace2env.models import ( | |
| ActionSchema, | |
| ArgumentSpec, | |
| Confidence, | |
| Demonstration, | |
| EnvironmentNote, | |
| Episode, | |
| Invariant, | |
| LocalTransitionEvidence, | |
| NormalizedAction, | |
| NoteInductionResult, | |
| Outcome, | |
| RawEvent, | |
| ReconstructionArtifacts, | |
| ReconstructionConfig, | |
| RenderContract, | |
| RenderInductionResult, | |
| RuleInductionResult, | |
| SchemaInductionResult, | |
| ActionSpec, | |
| SourceRef, | |
| StateField, | |
| StateSchema, | |
| TransitionRule, | |
| TransitionSlice, | |
| TraceAnnotations, | |
| SplitManifest, | |
| ) | |
| from trace2env.prompts import ( | |
| LOCAL_EVIDENCE_EXTRACTOR, | |
| NOTE_INDUCER, | |
| RENDERER_INDUCER, | |
| RULE_FALSIFIER, | |
| RULE_INDUCER, | |
| SCHEMA_INDUCER, | |
| TRACE_NORMALIZER, | |
| ) | |
| from trace2env.storage import write_json, write_jsonl | |
| def _json_payload(value: object) -> str: | |
| if hasattr(value, "model_dump"): | |
| value = value.model_dump(mode="json") # type: ignore[union-attr] | |
| return json.dumps(value, ensure_ascii=False, indent=2) | |
| DESCRIPTION_CHARS = 4000 | |
| # Event metadata that repeats the event content or carries the task agent's own commentary; neither is | |
| # environment evidence, so the extractor does not see it. | |
| EXCLUDED_EVENT_METADATA = {"raw_action", "agent_message", "raw_timestamp"} | |
| def clip_text(text: str, limit: int) -> tuple[str, bool]: | |
| """Head-and-tail clip with an explicit marker; ``limit`` 0 disables clipping.""" | |
| if not limit or len(text) <= limit: | |
| return text, False | |
| head, tail = text[: limit // 2], text[-(limit - limit // 2):] | |
| return f"{head}\n... [{len(text) - limit} characters omitted] ...\n{tail}", True | |
| def clip_leaves(value: Any, limit: int) -> tuple[Any, bool]: | |
| """Clip every string leaf of a JSON value; containers are rebuilt, nothing else changes.""" | |
| if isinstance(value, str): | |
| return clip_text(value, limit) | |
| if isinstance(value, dict): | |
| clipped = False | |
| out = {} | |
| for key, item in value.items(): | |
| out[key], flag = clip_leaves(item, limit) | |
| clipped = clipped or flag | |
| return out, clipped | |
| if isinstance(value, list): | |
| items = [clip_leaves(item, limit) for item in value] | |
| return [item for item, _ in items], any(flag for _, flag in items) | |
| return value, False | |
| def event_view(event: RawEvent, limit: int) -> dict: | |
| """What the evidence extractor sees of one event: identity, actor/kind, (clipped) content, plain metadata.""" | |
| content = event.content | |
| clipped = False | |
| if isinstance(content, dict) and "raw" in content and "arguments" in content: | |
| # A normalized action repeats its keystrokes under ``raw``; the arguments already hold them. | |
| content = {key: item for key, item in content.items() if key != "raw"} | |
| if isinstance(content, str): | |
| content, clipped = clip_text(content, limit) | |
| elif isinstance(content, (dict, list)): | |
| content, clipped = clip_leaves(content, limit) | |
| view = { | |
| "id": event.id, | |
| "actor": event.actor.value, | |
| "kind": event.kind.value, | |
| "content": content, | |
| "metadata": {key: value for key, value in event.metadata.items() if key not in EXCLUDED_EVENT_METADATA}, | |
| } | |
| if event.timestamp is not None: | |
| view["timestamp"] = event.timestamp.isoformat() | |
| if clipped: | |
| view["clipped"] = True | |
| return view | |
| def canonical_evidence(evidence: list[LocalTransitionEvidence], schema: SchemaInductionResult) -> list[LocalTransitionEvidence]: | |
| """Evidence rewritten to the induced vocabulary: action types through action aliases, mutation paths | |
| and fact subjects through state-field aliases. Returns copies; the extracted records stay as written.""" | |
| actions = action_aliases(schema.action_schema) | |
| paths = state_path_aliases(schema.state_schema) | |
| canonical: list[LocalTransitionEvidence] = [] | |
| for item in evidence: | |
| copy = item.model_copy(deep=True) | |
| copy.action = copy.action.model_copy(update={"type": actions.get(copy.action.type, copy.action.type)}) | |
| for mutation in copy.mutations: | |
| mutation.path = canonical_state_path(mutation.path, paths) | |
| for fact in copy.preconditions + copy.observation_facts: | |
| fact.subject = canonical_state_path(fact.subject, paths) | |
| canonical.append(copy) | |
| return canonical | |
| def declare_observed_actions(schema: SchemaInductionResult, evidence: list[LocalTransitionEvidence]) -> list[str]: | |
| """Every action type the evidence records is declared in the action schema. | |
| The schema inducer merges and describes the actions it sees, but on small corpora it can leave a rare | |
| type out (one ``python`` turn among fifty ``shell`` turns), and a package cannot compile with evidence | |
| whose action is undeclared. Missing types are added with an empty argument list (``declare_observed_arguments`` | |
| fills the arguments afterwards), a description saying they were not described by the inducer, and the | |
| evidence records as provenance; each addition is reported. Deterministic, no model call. | |
| """ | |
| aliases = action_aliases(schema.action_schema) | |
| declared = {spec.name for spec in schema.action_schema.actions} | |
| observed: dict[str, list[LocalTransitionEvidence]] = {} | |
| for item in evidence: | |
| name = aliases.get(item.action.type, item.action.type) | |
| if name not in declared: | |
| observed.setdefault(name, []).append(item) | |
| notes: list[str] = [] | |
| for name, items in sorted(observed.items()): | |
| provenance = [ref for item in items[:5] for ref in item.provenance][:10] | |
| schema.action_schema.actions.append(ActionSpec( | |
| name=name, description=f"Observed in {len(items)} transition(s) of the traces; not described by the schema inducer.", | |
| provenance=provenance)) | |
| declared.add(name) | |
| notes.append(f"action {name!r} was used by {len(items)} evidence record(s) but not declared by the schema inducer; declared from the evidence") | |
| return notes | |
| def declare_observed_arguments(schema: SchemaInductionResult, evidence: list[LocalTransitionEvidence]) -> list[str]: | |
| """Every argument name the evidence records for an action is declared on its ActionSpec. | |
| The rule and renderer stages may only reference declared arguments, so an argument the schema | |
| inducer left out would quarantine every rule that uses it. Missing names are added with the | |
| JSON type observed in the evidence (``any`` when it varies); each addition is reported. | |
| """ | |
| aliases = action_aliases(schema.action_schema) | |
| specs = {spec.name: spec for spec in schema.action_schema.actions} | |
| observed: dict[str, dict[str, set[str]]] = {} | |
| for item in evidence: | |
| name = aliases.get(item.action.type, item.action.type) | |
| for argument, value in item.action.arguments.items(): | |
| try: | |
| kind = value_type(value, None) | |
| except ValueError: # literal text that happens to look like a template token | |
| kind = "any" | |
| observed.setdefault(name, {}).setdefault(argument, set()).add(kind) | |
| notes: list[str] = [] | |
| for name, arguments in observed.items(): | |
| spec = specs.get(name) | |
| if spec is None: | |
| continue | |
| for argument, types in arguments.items(): | |
| if argument in spec.arguments: | |
| continue | |
| kind = next(iter(types)) if len(types) == 1 else "any" | |
| spec.arguments[argument] = ArgumentSpec(type=kind if kind in ARGUMENT_TYPES else "any", | |
| description="Declared from the evidence records of this action") | |
| notes.append(f"Argument {argument!r} of action {name!r} was observed in evidence but not declared; added as {kind}") | |
| return notes | |
| ARGUMENT_TYPES = {"string", "integer", "number", "boolean", "object", "array", "any"} | |
| REFUSED_MARKER = "Evidence extraction refused by the model provider" | |
| INVALID_MARKER = "Evidence extraction produced invalid structured output after repair attempts" | |
| def repair_schema_aliases(schema: SchemaInductionResult) -> list[str]: | |
| """Drop induced aliases that cannot be resolved: an alias equal to another declared action name or state path, an | |
| alias claimed by two entries (dropped from both), or an entry's own name. The inducer produced all three on the | |
| android phone corpora (``surface.chrome.viewport`` as the alias of two fields, a field path listed as another | |
| field's alias); ``action_aliases`` / ``state_path_aliases`` would otherwise raise and fail the stage. Every drop | |
| is reported.""" | |
| notes: list[str] = [] | |
| for kind, entries, key in (("action", schema.action_schema.actions, "name"), ("state field", schema.state_schema.fields, "path")): | |
| names = {getattr(entry, key) for entry in entries} | |
| claims: dict[str, list[str]] = {} | |
| for entry in entries: | |
| for alias in entry.aliases: | |
| claims.setdefault(alias, []).append(getattr(entry, key)) | |
| for entry in entries: | |
| kept = [] | |
| for alias in entry.aliases: | |
| own = getattr(entry, key) | |
| if alias == own: | |
| continue # redundant: an entry is its own alias | |
| if alias in names: | |
| notes.append(f"Dropped alias {alias!r} of {kind} {own!r}: it is a declared {kind} itself") | |
| elif len(set(claims[alias])) > 1: | |
| notes.append(f"Dropped alias {alias!r} of {kind} {own!r}: also claimed by {sorted(set(claims[alias]) - {own})}") | |
| elif alias in kept: | |
| continue | |
| else: | |
| kept.append(alias) | |
| if kept != list(entry.aliases): | |
| entry.aliases = kept | |
| return notes | |
| def declare_observed_paths(schema: SchemaInductionResult, evidence: list[LocalTransitionEvidence]) -> list[str]: | |
| """Every state path the (canonicalized) evidence mutates or asserts is declared in the state schema. | |
| The inducer may declare only a child (``world.mailman.lists``) while evidence also edits the | |
| parent (``world.mailman``), or skip a rarely used path; rules and tracked mutations on an | |
| undeclared path would be quarantined. Missing paths are declared with the type observed in the | |
| evidence (``any`` when it varies) and reported. | |
| """ | |
| paths = state_path_aliases(schema.state_schema) | |
| observed: dict[str, set[str]] = {} | |
| for item in evidence: | |
| for mutation in item.mutations: | |
| path = canonical_state_path(mutation.path, paths) | |
| kind = "object" if mutation.op in {"merge", "remove"} else None | |
| if kind is None: | |
| try: | |
| kind = value_type(mutation.value, None) | |
| except ValueError: | |
| kind = "any" | |
| observed.setdefault(path, set()).add(kind) | |
| for fact in item.preconditions + item.observation_facts: | |
| path = canonical_state_path(fact.subject, paths) | |
| if fact.predicate in {"contains", "not_contains"}: | |
| observed.setdefault(path, set()).add("object" if isinstance(fact.value, dict) else "array") | |
| elif fact.predicate in {"equals", "eq", "="}: | |
| try: | |
| observed.setdefault(path, set()).add(value_type(fact.value, None)) | |
| except ValueError: | |
| observed.setdefault(path, set()).add("any") | |
| notes: list[str] = [] | |
| for path, kinds in sorted(observed.items()): | |
| try: | |
| state_field(path, schema.state_schema, None) | |
| continue | |
| except ValueError: | |
| pass | |
| namespace = path.split(".", 1)[0] | |
| if namespace not in {"world", "session", "surface", "epistemic"}: | |
| continue | |
| kind = next(iter(kinds)) if len(kinds) == 1 else "any" | |
| schema.state_schema.fields.append(StateField( | |
| path=path, type=kind if kind in ARGUMENT_TYPES else "any", visibility=namespace, # type: ignore[arg-type] | |
| description="Declared from evidence: the schema stage did not declare this path")) | |
| notes.append(f"State path {path!r} was used by the evidence but not declared; added as {kind}") | |
| return notes | |
| def refused_evidence(transition: TransitionSlice, action_events: list[RawEvent], reason: str, *, | |
| marker: str = REFUSED_MARKER, rationale: str = "provider refusal") -> LocalTransitionEvidence: | |
| """A zero-confidence record for a slice that yielded no usable evidence (refusal or invalid output); no claims.""" | |
| content = action_events[0].content if action_events else None | |
| if isinstance(content, dict) and isinstance(content.get("type"), str): | |
| action = NormalizedAction(type=content["type"], arguments=dict(content.get("arguments") or {})) | |
| else: | |
| action = NormalizedAction(type="unknown", arguments={}, raw=content) | |
| return LocalTransitionEvidence( | |
| id=f"evidence_{transition.id}", episode_id=transition.episode_id, transition_id=transition.id, | |
| action=action, outcome=Outcome.UNKNOWN, confidence=Confidence(value=0.0, rationale=rationale), | |
| ambiguities=[f"{marker}: {reason[:300]}"], | |
| ) | |
| def refused_count(evidence: list[LocalTransitionEvidence]) -> int: | |
| return sum(1 for item in evidence if any(note.startswith(REFUSED_MARKER) for note in item.ambiguities)) | |
| def invalid_count(evidence: list[LocalTransitionEvidence]) -> int: | |
| """Slices whose extraction never produced a valid record; they carry zero confidence and no claims.""" | |
| return sum(1 for item in evidence if any(note.startswith(INVALID_MARKER) for note in item.ambiguities)) | |
| def environment_payload(config: ReconstructionConfig) -> dict: | |
| """Configuration as model context; a long domain description is clipped so it does not eat the byte budget.""" | |
| payload = config.model_dump(mode="json") | |
| if len(payload.get("description") or "") > DESCRIPTION_CHARS: | |
| payload["description"] = payload["description"][:DESCRIPTION_CHARS] + "\n... [description truncated]" | |
| return payload | |
| SCOPE_KEYS = {"environment_id", "tenant", "tenant_scope", "environment_version", "version"} | |
| NAMESPACES = {"world", "session", "surface", "epistemic"} | |
| UNSET_SCOPE_VALUES = {"", "none", "null", "unknown", "n/a"} | |
| PLACEHOLDER = re.compile(r"<[A-Za-z][^<>\n]* [^<>\n]*>") # `<shell prompt>`, `<target output>`: prose, not output | |
| RENDERER_ID = re.compile(r"[A-Za-z0-9_.:-]{1,80}") | |
| def repair_rule_selectors(rule: TransitionRule) -> list[str]: | |
| """Keep a rule's scope and template within what the runtime can evaluate. | |
| Scope keys are matched against the runtime context (tenant, version, environment id) or | |
| against state paths; any other key — an episode id, a command string, a free-text qualifier — | |
| would make the rule silently ineligible forever, so it is dropped and reported. Scope values | |
| that spell "None" for an unset selector are dropped for the same reason. A template that | |
| contains placeholder prose (``<shell prompt>``) is not literal output; it is removed so the | |
| renderer contract or the agent renders the observation instead. | |
| """ | |
| notes: list[str] = [] | |
| dropped_keys = [key for key in rule.scope | |
| if key not in SCOPE_KEYS and key.split(".", 1)[0] not in NAMESPACES] | |
| dropped_unset = [key for key, value in rule.scope.items() | |
| if key not in dropped_keys and str(value).strip().lower() in UNSET_SCOPE_VALUES] | |
| if dropped_keys or dropped_unset: | |
| rule.scope = {key: value for key, value in rule.scope.items() if key not in dropped_keys + dropped_unset} | |
| if dropped_keys: | |
| notes.append(f"Rule {rule.id}: scope keys {dropped_keys} are neither tenant/version/environment selectors " | |
| "nor state paths and were dropped (they would never match a runtime context)") | |
| if dropped_unset: | |
| notes.append(f"Rule {rule.id}: scope entries {dropped_unset} carried an unset value and were dropped") | |
| if rule.observation_template and PLACEHOLDER.search(rule.observation_template): | |
| notes.append(f"Rule {rule.id}: observation_template contained placeholder prose " | |
| f"({PLACEHOLDER.search(rule.observation_template).group(0)!r}) and was removed") | |
| rule.observation_template = None | |
| return notes | |
| def reconcile_artifacts( | |
| schema: SchemaInductionResult, | |
| rules: list[TransitionRule], | |
| invariants: list[Invariant], | |
| renderers: list[RenderContract], | |
| notes: list[EnvironmentNote], | |
| ) -> tuple[list[TransitionRule], list[Invariant], list[RenderContract], list[EnvironmentNote], list[str], list[dict]]: | |
| """Close references across independently induced stages instead of failing the whole build. | |
| Renderer ids that rules cite but no contract defines get a contract synthesized from the rule; | |
| rules, invariants, contracts, and notes that still violate the schema are quarantined with a | |
| recorded reason. Nothing is silently dropped: every decision lands in ``unresolved``. | |
| """ | |
| actions = {action.name: action for action in schema.action_schema.actions} | |
| state_schema = schema.state_schema | |
| unresolved: list[str] = [] | |
| rejected: list[dict] = [] | |
| contracts = {renderer.id: renderer for renderer in renderers} | |
| for rule in rules: | |
| unresolved.extend(repair_rule_selectors(rule)) | |
| if rule.renderer not in contracts and rule.action_type in actions: | |
| contracts[rule.renderer] = RenderContract( | |
| id=rule.renderer, action_types=[rule.action_type], | |
| instructions=f"Contract synthesized at compile time for rule {rule.id}: {rule.description}", | |
| provenance=list(rule.provenance), | |
| ) | |
| unresolved.append(f"Renderer {rule.renderer!r} was referenced by rule {rule.id} but not induced; a " | |
| "contract without a template was synthesized") | |
| kept_rules: list[TransitionRule] = [] | |
| for rule in rules: | |
| issues = rule_issues(rule, actions, state_schema, set(contracts)) | |
| if issues: | |
| unresolved.append(f"Rule {rule.id} quarantined: " + "; ".join(issues)) | |
| rejected.append({"kind": "rule", "id": rule.id, "issues": issues, "artifact": rule.model_dump(mode="json")}) | |
| else: | |
| kept_rules.append(rule) | |
| kept_invariants: list[Invariant] = [] | |
| for invariant in invariants: | |
| try: | |
| for operand in (invariant.condition.left, invariant.condition.right): | |
| if operand is not None and operand.path is not None: | |
| state_field(operand.path, state_schema, None) | |
| if operand is not None and operand.action_arg is not None: | |
| # Invariants hold on state alone; an action-argument operand cannot be checked without an action. | |
| raise ValueError(f"invariants cannot reference action arguments (action_arg={operand.action_arg!r})") | |
| kept_invariants.append(invariant) | |
| except ValueError as exc: | |
| unresolved.append(f"Invariant {invariant.id} quarantined: {exc}") | |
| rejected.append({"kind": "invariant", "id": invariant.id, "issues": [str(exc)], | |
| "artifact": invariant.model_dump(mode="json")}) | |
| kept_renderers: list[RenderContract] = [] | |
| for renderer in contracts.values(): | |
| if renderer.template and PLACEHOLDER.search(renderer.template): | |
| unresolved.append(f"Renderer {renderer.id}: template contained placeholder prose " | |
| f"({PLACEHOLDER.search(renderer.template).group(0)!r}) and was removed; its instructions remain") | |
| renderer = renderer.model_copy(update={"template": None}) | |
| contracts[renderer.id] = renderer | |
| known = [action_type for action_type in renderer.action_types if action_type in actions] | |
| if len(known) != len(renderer.action_types): | |
| unresolved.append(f"Renderer {renderer.id} referenced unknown actions " | |
| f"{sorted(set(renderer.action_types) - set(known))}; they were dropped") | |
| renderer = renderer.model_copy(update={"action_types": known}) | |
| bad_fields, bad_template = invalid_render_references(renderer, actions, state_schema, kept_rules) | |
| if bad_fields: | |
| # Prose labels and invented paths are not checkable at runtime; the instructions still are. | |
| dropped = [path for path, _ in bad_fields] | |
| unresolved.append(f"Renderer {renderer.id}: required fields {dropped} are not renderable references " | |
| f"({bad_fields[0][1]}) and were dropped; its instructions remain") | |
| renderer = renderer.model_copy(update={"required_fields": [p for p in renderer.required_fields if p not in dropped]}) | |
| contracts[renderer.id] = renderer | |
| if bad_template: | |
| unresolved.append(f"Renderer {renderer.id}: template referenced {[path for path, _ in bad_template]} " | |
| f"({bad_template[0][1]}); the template was removed and its instructions remain") | |
| renderer = renderer.model_copy(update={"template": None}) | |
| contracts[renderer.id] = renderer | |
| issues = renderer_issues(renderer, actions, state_schema, kept_rules) | |
| if issues: | |
| unresolved.append(f"Renderer {renderer.id} quarantined: " + "; ".join(issues)) | |
| rejected.append({"kind": "renderer", "id": renderer.id, "issues": issues, "artifact": renderer.model_dump(mode="json")}) | |
| kept_rules = [rule if rule.renderer != renderer.id else rule.model_copy(update={"renderer": "default"}) | |
| for rule in kept_rules] | |
| else: | |
| kept_renderers.append(renderer) | |
| if any(rule.renderer == "default" for rule in kept_rules) and "default" not in {r.id for r in kept_renderers}: | |
| kept_renderers.append(RenderContract(id="default", instructions="Fallback contract: render from the resulting state.")) | |
| kept_notes: list[EnvironmentNote] = [] | |
| for note in notes: | |
| unknown = sorted(set(note.action_types) - set(actions)) | |
| if unknown: | |
| unresolved.append(f"Note {note.id} referenced unknown actions {unknown}; it now applies to all actions") | |
| note = note.model_copy(update={"action_types": [a for a in note.action_types if a in actions]}) | |
| kept_notes.append(note) | |
| return kept_rules, kept_invariants, kept_renderers, kept_notes, unresolved, rejected | |
| def reconcile_provenance( | |
| schema: SchemaInductionResult, | |
| rules: list[TransitionRule], | |
| invariants: list[Invariant], | |
| renderers: list[RenderContract], | |
| notes: list[EnvironmentNote], | |
| episodes: list[Episode], | |
| evidence: list[LocalTransitionEvidence], | |
| transitions: list[TransitionSlice], | |
| ) -> list[str]: | |
| """Repair citations in every induced artifact in place; downgrade supported claims that lose their support.""" | |
| unresolved: list[str] = [] | |
| by_transition = {transition.id: transition for transition in transitions} | |
| for spec in schema.action_schema.actions: | |
| spec.provenance = repair_provenance(spec.provenance, episodes, unresolved, f"action {spec.name}") | |
| for field in schema.state_schema.fields: | |
| field.provenance = repair_provenance(field.provenance, episodes, unresolved, f"state field {field.path}") | |
| def anchored(refs: list[SourceRef], owner: str) -> list[SourceRef]: | |
| """Executable claims need event anchors; a citation of a whole episode or source is not one.""" | |
| kept = [ref for ref in refs if ref.episode_id and ref.event_ids] | |
| if len(kept) != len(refs): | |
| unresolved.append(f"{owner}: dropped {len(refs) - len(kept)} citation(s) without event anchors") | |
| return kept | |
| for rule in rules: | |
| rule.provenance = repair_provenance(rule.provenance, episodes, unresolved, f"rule {rule.id}") | |
| rule.counterexamples = repair_provenance(rule.counterexamples, episodes, unresolved, f"rule {rule.id} counterexamples") | |
| if rule.status == "supported": | |
| rule.provenance = anchored(rule.provenance, f"rule {rule.id}") | |
| if rule.status == "supported" and not rule.provenance: | |
| rule.status = "tentative" | |
| unresolved.append(f"Rule {rule.id} downgraded to tentative: no event-anchored provenance") | |
| elif rule.status == "supported" and not rule_has_observed_support(rule, evidence, by_transition): | |
| rule.status = "tentative" | |
| unresolved.append(f"Rule {rule.id} downgraded to tentative: no unambiguous observed transition supports it") | |
| kept_invariants = [] | |
| for invariant in invariants: | |
| invariant.provenance = anchored(repair_provenance(invariant.provenance, episodes, unresolved, f"invariant {invariant.id}"), | |
| f"invariant {invariant.id}") | |
| if invariant.provenance: | |
| kept_invariants.append(invariant) | |
| else: | |
| unresolved.append(f"Invariant {invariant.id} dropped: no event-anchored provenance") | |
| invariants[:] = kept_invariants | |
| for renderer in renderers: | |
| renderer.provenance = repair_provenance(renderer.provenance, episodes, unresolved, f"renderer {renderer.id}") | |
| for note in notes: | |
| note.provenance = repair_provenance(note.provenance, episodes, unresolved, f"note {note.id}") | |
| if note.status == "supported": | |
| note.provenance = anchored(note.provenance, f"note {note.id}") | |
| if note.status == "supported" and not note.provenance: | |
| note.status = "tentative" | |
| unresolved.append(f"Note {note.id} downgraded to tentative: no event-anchored provenance") | |
| return unresolved | |
| class TraceReconstructor: | |
| """Stages LLM judgment so local evidence remains auditable and revisable.""" | |
| def __init__(self, llm: StructuredLLM, config: ReconstructionConfig, work_dir: str | Path, workers: int = 1): | |
| self.llm = llm | |
| self.config = config | |
| self.work_dir = Path(work_dir) | |
| self.workers = max(1, workers) # concurrent extract_transition calls; every other stage is sequential | |
| self.refused_episodes: set[str] = set() # episodes with a provider-refused slice: suspects for induction refusals | |
| self.bounded = BoundedInduction(llm, config.max_prompt_bytes, config.induction_batch_size, suspect=self._suspect) | |
| self.source_snapshots: dict[str, str] = {} | |
| self.split_assignments = {} | |
| self.episode_groups = {} | |
| self.episode_scopes = {} | |
| def ingest(self, paths: Iterable[str | Path], split_manifest: SplitManifest | None = None) -> tuple[list[Episode], list[TransitionSlice]]: | |
| episodes = [] | |
| self.source_snapshots = {} | |
| self.episode_groups = {} | |
| self.episode_scopes = {} | |
| self.split_assignments = split_manifest.assignments if split_manifest else {} | |
| seen = set() | |
| for path in paths: | |
| loaded = load_raw_traces(path) | |
| raw = Path(path).read_bytes() | |
| source_id = "src_" + hashlib.sha256(raw).hexdigest() | |
| if any(ep.source_id != source_id for ep in loaded): | |
| raise ValueError("Source changed while being ingested") | |
| assignment = self.split_assignments.get(source_id) | |
| if split_manifest and (assignment is None or assignment.split != "train"): | |
| raise ValueError("Reconstruction input is absent from the training split manifest") | |
| self.source_snapshots[source_id] = raw.decode("utf-8") | |
| for ep in loaded: | |
| if ep.metadata.get("split", "train") != "train": | |
| raise ValueError("Held-out episodes cannot be used for reconstruction") | |
| if assignment: | |
| ep.metadata["trajectory_group"] = assignment.trajectory_group | |
| ep.metadata["split"] = assignment.split | |
| context = construction_scope(self.config) | |
| for key in ("tenant", "environment_version", "environment_id"): | |
| if key in ep.metadata: | |
| if key in context and context[key] != str(ep.metadata[key]): | |
| raise ValueError(f"Input episode contradicts construction scope: {key}") | |
| context[key] = str(ep.metadata[key]) | |
| self.episode_scopes[ep.id] = context | |
| self.episode_groups[ep.id] = str(ep.metadata.get("trajectory_group", ep.id)) | |
| signature = _json_payload([ep.metadata.get("original_episode_id"), | |
| ep.metadata.get("trajectory_group"), [(e.actor.value, e.kind.value, e.content) for e in ep.events]]) | |
| if signature in seen: | |
| continue | |
| seen.add(signature) | |
| episodes.append(ep) | |
| write_json(self.work_dir / "raw" / "sources.json", self.source_snapshots) | |
| write_jsonl(self.work_dir / "raw" / "episodes.jsonl", episodes) | |
| normalized: list[Episode] = [] | |
| for episode in episodes: | |
| kinds = {event.kind.value for event in episode.events} | |
| if "action" not in kinds or not ({"observation", "error", "state"} & kinds): | |
| annotations = self.bounded.call(TRACE_NORMALIZER, | |
| {"raw_episode": episode.model_dump(mode="json")}, TraceAnnotations, "normalize_trace") | |
| normalized.append(annotate_episode(episode, annotations)) | |
| else: | |
| normalized.append(episode) | |
| episodes = normalized | |
| transitions = [ | |
| transition | |
| for episode in episodes | |
| for transition in segment_transitions(episode, self.config.max_history_events) | |
| ] | |
| self.work_dir.mkdir(parents=True, exist_ok=True) | |
| write_jsonl(self.work_dir / "normalized" / "episodes.jsonl", episodes) | |
| write_jsonl(self.work_dir / "normalized" / "transitions.jsonl", transitions) | |
| return episodes, transitions | |
| def extraction_payload(self, episode: Episode, transition: TransitionSlice) -> dict: | |
| """The exact user payload one ``extract_transition`` call receives (also used to size prompts). | |
| Events are shown once, as content plus metadata (no raw payload copy, no task-agent | |
| commentary), and long text is clipped head-and-tail with an explicit omission marker: | |
| history events to ``extraction_history_chars``, the slice's own events to | |
| ``extraction_event_chars``. The recorded ``observation_text`` is taken from the source | |
| events afterwards, so clipping never changes what the evidence stores. | |
| """ | |
| selected = events_for_slice(episode, transition) | |
| limits = {"history": self.config.extraction_history_chars} | |
| return { | |
| "environment_context": { | |
| "environment_id": self.config.environment_id, | |
| "description": environment_payload(self.config)["description"], | |
| "domains": self.config.domains, | |
| "tenant_scope": self.config.tenant_scope, | |
| }, | |
| "transition": transition.model_dump(mode="json"), | |
| "episode_scope": self.episode_scopes.get(episode.id, {}), | |
| "events": { | |
| key: [event_view(event, limits.get(key, self.config.extraction_event_chars)) for event in values] | |
| for key, values in selected.items() | |
| }, | |
| "requirements": { | |
| "evidence_id": f"evidence_{transition.id}", | |
| "episode_id": episode.id, | |
| "transition_id": transition.id, | |
| "provenance_source_id": episode.source_id, | |
| }, | |
| } | |
| def _extract_one(self, episode: Episode, transition: TransitionSlice) -> LocalTransitionEvidence: | |
| selected = events_for_slice(episode, transition) | |
| try: | |
| result = self.bounded.call(LOCAL_EVIDENCE_EXTRACTOR, self.extraction_payload(episode, transition), | |
| LocalTransitionEvidence, "extract_transition") | |
| except ProviderRefusal as exc: | |
| # The provider would not look at this slice (content policy). Keep the transition with an | |
| # explicit zero-confidence record so the gap is visible instead of failing the stage. | |
| result = refused_evidence(transition, selected["actions"], str(exc)) | |
| except StructuredOutputError as exc: | |
| # The model never produced a record that satisfies the evidence contract. One malformed | |
| # answer must not fail a stage of hundreds of slices: record the gap, claim nothing. | |
| result = refused_evidence(transition, selected["actions"], str(exc), marker=INVALID_MARKER, | |
| rationale="invalid structured output") | |
| result.id = f"evidence_{transition.id}" | |
| result.episode_id = episode.id | |
| result.transition_id = transition.id | |
| result.observation_text = "\n".join(event.content if isinstance(event.content, str) | |
| else _json_payload(event.content) for event in selected["observations"]) | |
| result.ambiguities = list(dict.fromkeys(result.ambiguities + transition.ambiguities)) | |
| if transition.alignment == "ambiguous" or transition.concurrent_action_event_ids: | |
| result.mutations = [] | |
| result.confidence.value = min(result.confidence.value, 0.5) | |
| # Citations must point at events of this slice. Anything else is dropped and recorded, | |
| # never trusted; a record left without provenance falls back to the slice's own events. | |
| allowed_ids = set(transition.history_event_ids + transition.action_event_ids + transition.observation_event_ids) | |
| dropped = 0 | |
| def kept(refs: list[SourceRef]) -> list[SourceRef]: | |
| nonlocal dropped | |
| valid = [] | |
| for ref in refs: | |
| try: | |
| validate_source_ref(ref, {episode.id: episode}, require_event=True) | |
| except ValueError: | |
| dropped += 1 | |
| continue | |
| if not set(ref.event_ids) <= allowed_ids: | |
| dropped += 1 | |
| continue | |
| valid.append(ref) | |
| return valid | |
| result.provenance = kept(result.provenance) | |
| for fact in result.preconditions + result.observation_facts: | |
| fact.provenance = kept(fact.provenance) | |
| if dropped: | |
| result.ambiguities.append(f"Extractor cited {dropped} event reference(s) outside its transition slice; they were dropped") | |
| if not result.provenance: | |
| result.provenance = [ | |
| SourceRef( | |
| source_id=episode.source_id, | |
| episode_id=episode.id, | |
| event_ids=transition.action_event_ids + transition.observation_event_ids, | |
| ) | |
| ] | |
| write_json(self.work_dir / "evidence" / f"{transition.id}.json", result) | |
| return result | |
| def extract_evidence( | |
| self, episodes: list[Episode], transitions: list[TransitionSlice] | |
| ) -> list[LocalTransitionEvidence]: | |
| episode_index = {episode.id: episode for episode in episodes} | |
| if self.workers > 1: | |
| from concurrent.futures import ThreadPoolExecutor | |
| with ThreadPoolExecutor(max_workers=self.workers) as pool: | |
| evidence = list(pool.map(lambda t: self._extract_one(episode_index[t.episode_id], t), transitions)) | |
| else: | |
| evidence = [self._extract_one(episode_index[t.episode_id], t) for t in transitions] | |
| write_jsonl(self.work_dir / "evidence" / "all.jsonl", evidence) | |
| return evidence | |
| def _suspect(self, record: dict) -> bool: | |
| """A record from an episode the provider already refused once is dropped first when a chunk is refused.""" | |
| if not isinstance(record, dict): | |
| return False | |
| if record.get("episode_id") in self.refused_episodes: | |
| return True | |
| return any(isinstance(ref, dict) and ref.get("episode_id") in self.refused_episodes | |
| for ref in record.get("provenance") or []) | |
| def _note_refusals(self, evidence: list[LocalTransitionEvidence], result, role: str) -> None: | |
| """Remember refused episodes and report records an induction stage had to exclude.""" | |
| self.refused_episodes |= {item.episode_id for item in evidence | |
| if any(note.startswith(REFUSED_MARKER) for note in item.ambiguities)} | |
| if self.bounded.refused: | |
| labels = sorted({str(record.get("id") or record.get("transition_id") or "?") for record in self.bounded.refused | |
| if isinstance(record, dict)}) | |
| result.unresolved.append( | |
| f"{len(self.bounded.refused)} record(s) were refused by the model provider during {role} and excluded " | |
| f"from this stage: {', '.join(labels[:12])}{' ...' if len(labels) > 12 else ''}") | |
| def induction_record(self, item: LocalTransitionEvidence) -> dict: | |
| """An evidence record as the induction stages see it: values and observation clipped, provenance kept.""" | |
| record = item.model_dump(mode="json") | |
| limit = self.config.induction_value_chars | |
| for mutation in record["mutations"]: | |
| mutation["value"], _ = clip_leaves(mutation["value"], limit) | |
| for key in ("preconditions", "observation_facts"): | |
| for fact in record[key]: | |
| fact["value"], _ = clip_leaves(fact["value"], limit) | |
| record["action"]["arguments"], _ = clip_leaves(record["action"]["arguments"], limit) | |
| record["action"].pop("raw", None) | |
| record["observation_text"], _ = clip_text(record["observation_text"], self.config.induction_observation_chars) | |
| return record | |
| def induce_schema(self, evidence: list[LocalTransitionEvidence]) -> SchemaInductionResult: | |
| self._note_refusals(evidence, SchemaInductionResult(action_schema=ActionSchema(actions=[]), | |
| state_schema=StateSchema(fields=[])), "induce_schema") | |
| result = self.bounded.induce( | |
| [{**self.induction_record(item), "observed_scope": self.episode_scopes.get(item.episode_id, {})} | |
| for item in sorted(evidence, key=lambda e: e.id)], | |
| SCHEMA_INDUCER, SchemaInductionResult, "induce_schema", | |
| {"environment": environment_payload(self.config)}, "local_evidence", "partial_schemas") | |
| self._note_refusals(evidence, result, "induce_schema") | |
| result.unresolved.extend(repair_schema_aliases(result)) | |
| result.unresolved.extend(declare_observed_actions(result, evidence)) | |
| result.unresolved.extend(declare_observed_arguments(result, evidence)) | |
| result.unresolved.extend(declare_observed_paths(result, evidence)) | |
| write_json(self.work_dir / "induction" / "schema.json", result) | |
| return result | |
| def induce_rules( | |
| self, evidence: list[LocalTransitionEvidence], schema: SchemaInductionResult | |
| ) -> RuleInductionResult: | |
| cohorts = {} | |
| for item in sorted(evidence, key=lambda e: (e.action.type, e.id)): | |
| cohorts.setdefault(item.action.type, []).append(item) | |
| contrasts = build_contrasts(evidence, self.episode_groups, self.episode_scopes) | |
| write_json(self.work_dir / "induction" / "contrast_sets.json", contrasts) | |
| contrast_index = {} | |
| for contrast in contrasts: | |
| for key in ("success_evidence_id", "failure_evidence_id"): | |
| contrast_index.setdefault(contrast[key], []).append(contrast) | |
| records = [] | |
| for items in cohorts.values(): | |
| for item in items: | |
| records.append({**self.induction_record(item), | |
| "observed_scope": self.episode_scopes.get(item.episode_id, {}), | |
| "matched_contrasts": contrast_index.get(item.id, [])[:2]}) | |
| base = {"environment": environment_payload(self.config), | |
| "schema": schema.model_dump(mode="json")} | |
| self._note_refusals(evidence, RuleInductionResult(rules=[]), "induce_rules") | |
| proposal = self.bounded.induce( | |
| records, | |
| RULE_INDUCER, RuleInductionResult, "induce_rules", base, | |
| "local_evidence", "candidate_rule_sets") | |
| self._note_refusals(evidence, proposal, "induce_rules") | |
| write_json(self.work_dir / "induction" / "rules.proposed.json", proposal) | |
| payload = {**base, "proposed_rules": proposal.model_dump(mode="json"), | |
| "local_evidence": [self.induction_record(item) for item in evidence], | |
| "minimum_confidence": self.config.min_rule_confidence} | |
| if (len(evidence) <= self.config.induction_batch_size | |
| and self.bounded.size(RULE_FALSIFIER, payload, RuleInductionResult) <= self.config.max_prompt_bytes): | |
| reviewed = self.bounded.call(RULE_FALSIFIER, payload, RuleInductionResult, "falsify_rules") | |
| else: | |
| audits = [] | |
| # Audit each action's rules against every evidence record for that action. | |
| # The hard budget also catches oversized schemas or indivisible rule sets. | |
| for action, items in cohorts.items(): | |
| scoped = proposal.model_copy(update={"rules": [r for r in proposal.rules if r.action_type == action]}) | |
| audit = self.bounded.induce( | |
| [self.induction_record(item) for item in items], | |
| RULE_FALSIFIER, RuleInductionResult, "falsify_rules_chunk", | |
| {**base, "proposed_rules": scoped.model_dump(mode="json")}, | |
| "local_evidence", "batch_audits") | |
| audits.append(audit.model_dump(mode="json")) | |
| reviewed = self.bounded.reduce(audits, RULE_FALSIFIER, RuleInductionResult, | |
| "falsify_rules", base, "batch_audits") | |
| self._note_refusals(evidence, reviewed, "falsify_rules") | |
| reviewed.unresolved = list(dict.fromkeys(proposal.unresolved + reviewed.unresolved)) | |
| for rule in reviewed.rules: | |
| if not RENDERER_ID.fullmatch(rule.renderer): | |
| # The model described the output format where an identifier belongs; keep the description. | |
| reviewed.unresolved.append(f"Rule {rule.id}: renderer was prose, renamed to {rule.action_type}.{rule.id}") | |
| rule.description = f"{rule.description.rstrip('.')}. Rendering: {rule.renderer}" | |
| rule.renderer = f"{rule.action_type}.{rule.id}" | |
| # Persist complete reviewed candidates, including excluded rules and their effects. | |
| for rule in reviewed.rules: | |
| if rule.confidence < self.config.min_rule_confidence and rule.status == "supported": | |
| rule.status = "tentative" | |
| if rule.status != "supported": | |
| reviewed.unresolved.append(f"Candidate {rule.id} is not executable: {rule.status}") | |
| write_json(self.work_dir / "induction" / "rules.reviewed.json", reviewed) | |
| return reviewed | |
| def induce_renderers( | |
| self, evidence: list[LocalTransitionEvidence], rules: RuleInductionResult | |
| ) -> RenderInductionResult: | |
| outputs = [] | |
| for item in sorted(evidence, key=lambda e: e.id): | |
| record = self.induction_record(item) | |
| outputs.append({"action": record["action"], "outcome": record["outcome"], | |
| "observation": record["observation_text"], "facts": record["observation_facts"], | |
| "provenance": record["provenance"]}) | |
| required = sorted({rule.renderer for rule in rules.rules}) | |
| self._note_refusals(evidence, RenderInductionResult(renderers=[]), "induce_renderers") | |
| result = self.bounded.induce(outputs, RENDERER_INDUCER, RenderInductionResult, | |
| "induce_renderers", {"rules": rules.model_dump(mode="json"), "required_renderer_ids": required}, | |
| "observed_outputs", "partial_contracts") | |
| self._note_refusals(evidence, result, "induce_renderers") | |
| write_json(self.work_dir / "induction" / "renderers.json", result) | |
| return result | |
| def induce_notes( | |
| self, evidence: list[LocalTransitionEvidence], rules: RuleInductionResult, renderers: RenderInductionResult | |
| ) -> NoteInductionResult: | |
| """Conventions, formats, constraints, and facts that rules and contracts do not express.""" | |
| records = [] | |
| for item in sorted(evidence, key=lambda e: e.id): | |
| record = self.induction_record(item) | |
| records.append({"action": record["action"], "outcome": record["outcome"], | |
| "observation": record["observation_text"], | |
| "facts": record["observation_facts"] + record["preconditions"], | |
| "provenance": record["provenance"]}) | |
| self._note_refusals(evidence, NoteInductionResult(), "induce_notes") | |
| result = self.bounded.induce(records, NOTE_INDUCER, NoteInductionResult, "induce_notes", | |
| {"rules": rules.model_dump(mode="json"), "renderers": renderers.model_dump(mode="json")}, | |
| "observed_transitions", "partial_notes") | |
| self._note_refusals(evidence, result, "induce_notes") | |
| for note in result.notes: | |
| if note.confidence < self.config.min_rule_confidence and note.status == "supported": | |
| note.status = "tentative" | |
| write_json(self.work_dir / "induction" / "notes.json", result) | |
| return result | |
| def select_demonstrations(evidence: list[LocalTransitionEvidence], limit_per_action: int = 3, | |
| rules=None) -> list[Demonstration]: | |
| selected: list[Demonstration] = [] | |
| counts: dict[str, int] = {} | |
| remaining = sorted(evidence, key=lambda item: (-item.confidence.value, item.id)) | |
| outcomes, episodes = set(), set() | |
| while remaining: | |
| item = max(remaining, key=lambda e: ((e.action.type, e.outcome) not in outcomes, | |
| (e.action.type, e.episode_id) not in episodes, e.confidence.value)) | |
| remaining.remove(item) | |
| action_type = item.action.type | |
| if counts.get(action_type, 0) >= limit_per_action: | |
| continue | |
| counts[action_type] = counts.get(action_type, 0) + 1 | |
| outcomes.add((action_type, item.outcome)) | |
| episodes.add((action_type, item.episode_id)) | |
| before, after = {}, {} | |
| for facts, projection in [(item.preconditions, before), (item.observation_facts, after)]: | |
| for fact in facts: | |
| if (fact.status.value == "observed" and fact.provenance and fact.predicate in {"eq", "equals", "="} | |
| and fact.subject.split(".", 1)[0] in {"world", "session", "surface", "epistemic"}): | |
| set_path(projection, fact.subject, fact.value) | |
| event_ids = {event for ref in item.provenance for event in ref.event_ids} | |
| linked = [rule.id for rule in (rules or []) if rule.action_type == action_type | |
| and event_ids & {event for ref in rule.provenance for event in ref.event_ids}] | |
| selected.append( | |
| Demonstration( | |
| id=f"demo_{item.id}", | |
| action=item.action, | |
| observation=item.observation_text, | |
| state_before=before, state_after=after, rule_ids=linked, outcome=item.outcome, | |
| provenance=item.provenance | |
| or [SourceRef(source_id="unknown", episode_id=item.episode_id)], | |
| ) | |
| ) | |
| return selected | |
| def run(self, paths: Iterable[str | Path], output_dir: str | Path, | |
| split_manifest: SplitManifest | None = None) -> Path: | |
| if Path(output_dir).exists(): | |
| raise FileExistsError("Choose a new package destination before reconstruction") | |
| episodes, transitions = self.ingest(paths, split_manifest) | |
| if not transitions: | |
| raise ValueError("No action-to-observation transitions were found in the supplied traces") | |
| evidence = self.extract_evidence(episodes, transitions) | |
| schema = self.induce_schema(evidence) | |
| evidence = canonical_evidence(evidence, schema) | |
| rules = self.induce_rules(evidence, schema) | |
| renderers = self.induce_renderers(evidence, rules) | |
| notes = self.induce_notes(evidence, rules, renderers) | |
| kept_rules, kept_invariants, kept_renderers, kept_notes, reconciled, rejected = reconcile_artifacts( | |
| schema, rules.rules, rules.invariants, renderers.renderers, notes.notes) | |
| if rejected: | |
| write_json(self.work_dir / "induction" / "rejected.json", rejected) | |
| reconciled += reconcile_provenance(schema, kept_rules, kept_invariants, kept_renderers, kept_notes, | |
| episodes, evidence, transitions) | |
| unresolved = list(dict.fromkeys(schema.unresolved + rules.unresolved + renderers.unresolved | |
| + notes.unresolved + reconciled)) | |
| artifacts = ReconstructionArtifacts( | |
| episodes=episodes, | |
| transitions=transitions, | |
| evidence=evidence, | |
| action_schema=schema.action_schema, | |
| state_schema=schema.state_schema, | |
| rules=kept_rules, | |
| invariants=kept_invariants, | |
| renderers=kept_renderers, | |
| demonstrations=self.select_demonstrations(evidence, rules=kept_rules), | |
| notes=kept_notes, | |
| unresolved=unresolved, | |
| rejected=rejected, | |
| source_snapshots=self.source_snapshots, | |
| split_assignments=self.split_assignments, | |
| ) | |
| write_json(self.work_dir / "artifacts.json", artifacts) | |
| return EnvironmentCompiler(self.config).compile(artifacts, output_dir) | |