an.ir.sync

Bidirectional sync between scene.md (Narrative Layer) and ir/scene.json (Scene Graph Layer).

The Markdown form is what humans edit. The JSON form is what the agent and verifiers operate on. They must round-trip cleanly.

Markdown convention (v0.1, kept simple — extended in P5):

# <title>

Optional prose intro (saved to meta.notes).

`yaml meta title: Park Bench duration: 45 fps: 30 `

## Shot s1 (cutout)

Optional prose direction for this shot.

```yaml shot duration: 15 camera:

move: push_in

```

`dialogue charlie: Did you ever wonder why we always meet here? maya: Because the pigeons trust us. `

A shot heading is ## Shot <id> (<renderer>) — the parenthesised word names the RENDERER, and is captured positionally, so the heading is unchanged by the an#106 rename. Fenced blocks attach to the nearest enclosing scope. Unknown blocks are preserved as options so agent extensions don’t get clobbered on round-trip.

exception an.ir.sync.SceneMarkdownError[source]

scene.md says something this build cannot read — a refusal, not a crash.

Every parse refusal in this module raises it, so the CLI can tell “the human’s file needs one edit” apart from “something broke”. That distinction is the whole point of naming it: an#106’s first pass widened the CLI’s catch to bare ValueError to print these cleanly, which also swallowed json.JSONDecodeError and CutoutCompileError — both ValueError subclasses — and turned a failed render into exit 0.

exception an.ir.sync.SceneValidationError(message: str, validation_error=None)[source]

A stored scene document is not a valid scene — named, with its source.

The underlying pydantic.ValidationError is kept as __cause__ (and as .validation_error) so a caller that reports per fieldan.validate_schema builds one Finding per error, each with its own loc — does not have to choose between naming the document and naming the field.

class an.ir.sync.SyncResult(wrote_json: bool = False, wrote_md: bool = False, drift_warning: str | None = None)[source]

Outcome of a sync operation.

an.ir.sync.ir_to_markdown(scene: SceneIR) str[source]

Render a SceneIR back into the structured Markdown form.

>>> from an.ir.schema import SceneIR, Meta, Shot
>>> scene = SceneIR(meta=Meta(title="Demo", duration=5.0),
...                 timeline=[Shot(id="s1", renderer="cutout", duration=5.0)])
>>> md = ir_to_markdown(scene)
>>> "# Demo" in md
True
>>> "## Shot s1 (cutout)" in md
True
an.ir.sync.markdown_to_ir(md_text: str) SceneIR[source]

Parse the structured Markdown form of a scene into a SceneIR.

>>> md = '''# Demo
...
... ```yaml meta
... title: Demo
... duration: 5
... ```
...
... ## Shot s1 (cutout)
...
... ```yaml shot
... duration: 5
... ```
...
... ```dialogue
... charlie: hi
... ```
... '''
>>> scene = markdown_to_ir(md)
>>> scene.meta.title
'Demo'
>>> scene.timeline[0].id
's1'
>>> scene.timeline[0].dialogue[0].text
'hi'
an.ir.sync.scene_from_json_doc(doc: dict, *, source: str | Path | None = None) SceneIR[source]

Validate a stored scene document, migrating it first (an#105).

Every path from stored bytes to a SceneIR goes through here — the store (read and write), sync()’s two json-wins branches, and an.validate_schema, which is a read path too because a dict or a JSON string handed to it is a stored document. A test walks the package’s AST and fails on any other one. Before it existed, migrate() was called with kind=”CharacterDescriptor” at every call site in the tree and with a scene at none of them — so a registered scene migration never ran, and because SceneIR is extra="allow", a renamed field would have landed as a silent default on every document already on disk. Registering a migration and never running it is worse than not registering one, because the registry reads as a promise.

Three outcomes, three different repairs, so they get three messages:

  • a version this build reads (at or above COMPATIBLE_VERSION, at or below SCHEMA_VERSION) is taken as-is when no migration is registered for it — that is exactly what an/base.py promises, and a loader that demanded an exact match would refuse every stored project the day the version is bumped;

  • a version from the future is refused as written by a newer build, because nobody will ever register a downgrade;

  • anything else — an old version with no path, or a malformed field — is refused naming the document.

Migration happens on read, with no write-back: the document on disk keeps its old version until something saves the scene, so a migration must stay registered for as long as any project might hold that version. That is deliberate — a loader that rewrote every file it opened would turn an validate into a mutation — but it means the registry only ever grows.

>>> scene_from_json_doc({"version": "0.1.0", "meta": {"title": "t"}}).meta.title
't'
>>> scene_from_json_doc({"version": "0.0.1"}, source="ir/scene.json")
...
Traceback (most recent call last):
...
DocumentMigrationError
an.ir.sync.sync(project_dir: str | Path) SyncResult[source]

Reconcile scene.md and ir/scene.json inside a project directory.

Strategy in v0.1: Markdown is the human SSOT; if both exist, the JSON is regenerated from the Markdown unless mtimes show JSON is newer (which the user is told never to do — but we warn instead of silently overwriting).