# RENKIN SynPlanner Bridge — Design Doc

Status: **Phase 0 complete, Phase 1 PR1 (design + fixtures) done, PR1.5
(real MCTS-planning verification) done, PR2 (`src/bridge/synplanner.rs`,
the actual Rust normalizer) done, PR3 (cross-tool parity, CLI-level tests,
Playground example, docs guide) done.** Real fixtures live at
`tests/fixtures/synplanner/v1.6.0/` (see that directory's own
`PROVENANCE.md`/`real_planning_route.PROVENANCE.md` for exact construction/
reproduction detail). §7's open questions are all resolved. Phase 1 (the
SynPlanner adapter) is complete; PR4 (version bump/release) needs separate
authorization per this project's standing rule, and Phase 2+ of the
7-phase SynPlanner-surpass roadmap is next, not started.

## 0. What this is, in one paragraph

Unlike Syntheseus (no native export at all — RENKIN had to invent
`syntheseus-route-v1`), SynPlanner **does** ship a real, native route
export function (`synplan.chem.reaction.routes.write_routes_json`). This
round confirmed its exact contract by installing the real package and
running real chemistry through its real code (not guessed from the PyPI
page, not hand-typed JSON) — see
`docs/design/synplanner-v1.6-competitive-baseline.md` §1/§4 for the
artifact-provenance summary this doc builds on. This document is the
schema mapping and adapter architecture for consuming that real export
format into RENKIN's existing, unmodified `RouteDocument` pipeline; §7
lists what's still an open decision before Phase 1 PR2 (the actual Rust
normalizer) can start.

## 1. Existing-code grounding (read before designing, not after)

- **`src/bridge/route_graph.rs`** defines the **one shared internal
  representation** every adapter builds into: `RouteDocument` / `RouteNode`
  / `ReactionEvidence` / `RouteSource`. There is no per-adapter document
  type — a SynPlanner adapter reuses this exactly, adding a new
  `RouteSource::SynPlanner` variant and a new `ReactionEvidence` variant
  (see §3). `RouteSource` is currently a 3-variant enum (`Renkin`,
  `AiZynthFinder`, `Syntheseus`) whose doc comment still says "2-variant" —
  stale even before this round; whoever implements PR2 should fix that
  comment while adding the 4th variant.
- **`src/bridge/aizynthfinder.rs`** is the direct template, not
  `src/bridge/syntheseus.rs`. There are two adapter-construction patterns
  in this codebase: a private recursive tree-walker (AiZynthFinder, for
  nested-tree input) and a shared `route_graph::build()` + `StepInfo` +
  `LeafResolver` closure (RENKIN-native and Syntheseus, for flat-step-list
  input). SynPlanner's real export is `{route_id: RouteNode}` with
  `children: list[RouteNode]` nesting — structurally identical in shape to
  AiZynthFinder's `mol`/`reaction` alternating tree. **The AiZynthFinder
  pattern is the one to port**, including its fail-loud defect vocabulary
  (`RawOutputNotDecodable`, `UnparseableSmilesInRoute`,
  `DegenerateSelfReferentialStep`, `ChildlessNonLeaf`,
  `AmbiguousLeafStatus`) and its `document = parseable.then(...)` contract
  (a route with any defect is never partially trusted).
- **`src/bridge/forward.rs`**'s existing `not_evaluable(
  MissingReactionRepresentation | MissingAtomMapping)` machinery needs
  **zero new logic** either way. SynPlanner's schema has no *dedicated*
  atom-mapping field (confirmed, see §2 below) — but this round's real
  fixtures show the reaction node's own `smiles` string does carry atom
  maps (`[CH3:1][CH2:2][OH:3]>>[CH3:1][CH2:2][Cl:3]`), which would pass
  `forward.rs`'s existing permissive `has_atom_mapping` (`:` + digit) scan.
  A new `declared_smirks` match arm just needs to pass that `smiles` field
  through unchanged — whether the result is `not_evaluable:
  missing_atom_mapping` or an actual forward replay then depends on
  whether real (non-hand-constructed) SynPlanner output retains those
  maps the same way, which is **not yet confirmed** (see §7).
- **`src/bridge/audit_route.rs`**'s `detect_audit_route_format` is an
  ordered, most-specific-signal-first structural sniff. A SynPlanner
  branch needs its own distinguishing top-level marker; §3 proposes one
  grounded in this round's real fixture output, not guessed.

## 2. Phase 0 findings (the actual investigation, this round)

Full detail and construction code in
`tests/fixtures/synplanner/v1.6.0/PROVENANCE.md`; summarized here for the
adapter design itself.

- **The real input contract to SynPlanner's own exporter is not a
  `RouteNode` dict** — it's `routes_dict: dict[int, dict[int,
  chython.ReactionContainer]]` (route ID → step ID → real chemistry
  object). `_make_json_v1` walks backward from the final step, matching
  each reactant against an earlier step's product by **canonicalized,
  atom-map-stripped SMILES** (confirmed empirically: two occurrences of
  the same molecule with *different* atom-map numbers still link
  correctly — `str(mol)` after `kekule()`/`implicify_hydrogens()`/
  `thiele()` drops the map). This is irrelevant to the RENKIN-side
  adapter (which only ever sees the already-exported JSON), but explains
  why SynPlanner's own atom-map numbers in the *serialized* reaction
  `smiles` string are per-reaction-local and not guaranteed consistent
  across steps for the same molecule — worth noting if a future
  atom-mapping enrichment (§7) is attempted.
- **`in_stock` is always a definite boolean through this real code path,
  never absent/`None`** — true for every leaf with no tracked producing
  reaction, false for every reaction-produced molecule. It is **not
  independently re-verified against a stock database** by the export
  function itself; "no producing reaction in the given `routes_dict`" is
  the entire criterion. Practically: RENKIN's existing `AmbiguousLeafStatus`
  finding (used for AiZynthFinder's `in_stock: None` and Syntheseus's
  `is_purchasable: None`) may **never fire** for SynPlanner-sourced
  routes captured this way. This should be a real, tracked adapter
  behavior, not silently assumed impossible — a future SynPlanner version
  or a different call site could still produce `None` in principle.
- **`rule_id`/`rule_source`/`rule_key`/`step_id`/`tree_node_id` are opt-in
  via a `route_metadata` argument to the export call**, not automatically
  derived from the `Reaction` objects — they get `dict.update()`-merged
  directly onto the reaction node (siblings of `type`/`smiles`/`children`,
  not nested under `meta`). Whether a *real planning run's* own export
  call actually populates `route_metadata` (with real rule identifiers
  from the planning tree) was not confirmed this round — the round's
  fixtures pass synthetic-but-real metadata to prove the mechanism exists
  and round-trips correctly, not to claim real planning output always
  includes it.
- **`meta` is a separate field**, sourced from `chython.ReactionContainer.meta`
  (a real, independently-settable dict on the reaction object itself) —
  distinct from `route_metadata`.
- **Top-level file shape**: `{route_id: RouteNode}` — an **object keyed by
  route-ID string**, confirmed by running the real exporter (not assumed
  from the TypedDict's own declaration, which says nothing about the
  top-level wrapper). This directly informs §3's format-detection design.
- **Malformed-route handling**: `strict=True` raises `RouteExportError`
  carrying `RouteDiagnostic(route_id, stage, message)` tuples;
  `strict=False` silently drops the malformed route from the output dict
  while still returning the same diagnostics for inspection — confirmed
  by constructing a deliberately broken reaction (zero products) and
  running both modes.

## 3. Schema mapping

### 3.1 `RouteNode` (SynPlanner) → RENKIN `RouteNode` / `ReactionEvidence`

| SynPlanner field | On node type | RENKIN counterpart | Notes |
|---|---|---|---|
| `type` | both | (structural — determines mol vs. reaction dispatch in the walker) | Exactly mirrors AiZynthFinder's `mol`/`reaction` alternation. |
| `smiles` | mol | `RouteNode.canonical_smiles` | Canonicalize on parse, same as every other adapter. |
| `smiles` | reaction | Feeds a new `ReactionEvidence::SynPlannerReaction { smiles }` variant | This is the reaction-level SMILES/SMIRKS string (real atom maps present *within* one reaction, per-reaction-local — see §2). Passed to `forward.rs`'s existing `declared_smirks`/`has_atom_mapping` machinery unchanged. |
| `children` | both | `RouteNode.children` | Direct recursive walk, matching `azf_mol_to_route_node`'s pattern. |
| `in_stock` | mol only | `RouteNode.is_stock_leaf` | Per §2, always a definite bool through the real export path — but the adapter should still accept `Option<bool>` on parse (never assume the field is always present just because this round's evidence suggests it), treating a genuinely-absent value the same `AmbiguousLeafStatus` way AiZynthFinder does. |
| `meta` | reaction only | **No counterpart today.** Proposed: fold into a new `ReactionEvidence::SynPlannerReaction { smiles, meta: HashMap<String, serde_json::Value> }` field, kept but never interpreted (matches this codebase's "tolerant of unknown/future fields" convention). | Open question in §7: worth typing further, or keep as an opaque bag? |
| `step_id` | reaction only | **No counterpart today.** | Purely informational — SynPlanner's own step numbering, not needed for RENKIN's own tree structure (which is derived from JSON nesting, not step IDs). Candidate: drop, or keep in the same opaque `meta` bag as above. |
| `tree_node_id` | reaction only | **No counterpart today.** | Same treatment question as `step_id`. |
| `rule_id` / `rule_source` / `rule_key` | reaction only | **No counterpart in `ReactionEvidence` for *any* adapter today.** | Genuinely new capability RENKIN doesn't have anywhere yet — flagged as its own open question in §7, not unique to SynPlanner (a future rule-provenance-aware finding could use this for any adapter that has it). |
| (top-level wrapper) `{route_id: RouteNode}` | — | Each value becomes one `RouteDocument`; `route_id` itself has no RENKIN counterpart (RENKIN's own multi-route reports are keyed by array position, not a source-tool ID) — candidate: thread it through as an opaque provenance string on `AuditReport`, matching how the source format string itself is already recorded. | Open question in §7. |

### 3.2 Format auto-detection design

**Proposed signal**: a top-level JSON **object** whose values are
themselves objects with `"type": "mol"` at their root, **and** whose keys
parse as non-negative integers (route IDs). This must be checked *before*
the existing RENKIN native check (`target`+`routes` keys) and the
AiZynthFinder-batch check (`schema`+`data` keys), following the codebase's
existing "most specific signal first" convention — none of RENKIN's three
existing formats produce a bare `{"<int>": {"type": "mol", ...}}` shape,
so this is unambiguous against all three, but the exact precedence
ordering should be re-verified against real code once PR2 actually writes
`detect_audit_route_format`'s new match arm (not just asserted here).

Caveat, stated plainly rather than hidden: this signal was derived from
**two hand-constructed fixtures**, not a real planning run's actual batch
output. If a real multi-route planning run's top-level JSON shape differs
(e.g. wrapped in an outer envelope with metadata, the way AiZynthFinder's
batch mode adds a Pandas `schema`+`data` wrapper around its per-target
routes), this detection signal would need revisiting against that real
shape before Phase 1 PR2 ships. Recorded as an explicit open item in §7,
not silently assumed resolved.

**Resolved/upgraded in Phase 1 PR1.5 (§7, item 1)**: this heuristic checks
out against real search output too, but a *better* signal exists — the
real `--export_routes` artifact (`manifest.json` + `results.json.gz`) is
an explicitly versioned public contract whose manifest directly declares
`directives.adapter == "synplanner"`. PR2 should prefer that signal when
a manifest is available and keep this section's structural heuristic only
as the fallback for a bare `RouteNode`/`mapped_routes_N.json`-style input
with no accompanying manifest.

### 3.3 Stock / gap survey

- **Atom mapping**: confirmed absent as a *dedicated* schema field (§2),
  but the reaction `smiles` string itself does carry atom maps. **Resolved
  in Phase 1 PR1.5 (§7, item 2)**: confirmed against a real 167-route MCTS
  search, not just hand-constructed fixtures — real planning output's
  atom maps are structurally valid (no duplicates/orphans across 317
  reactions), cross-step consistent (150/150 byte-identical), and
  forward-replayable (RDKit-verified on 3 representative reactions).
  `forward.rs`'s `has_atom_mapping` gate passes every one of them — no new
  forward-validation code needed, and SynPlanner routes are genuinely
  forward-evaluable for the preset/target tested, unlike the
  `not_evaluable` outcome AiZynthFinder/Syntheseus always get.
- **Stock**: same three-valued pattern as AiZynthFinder (`Option<bool>`
  on parse), even though real-path evidence suggests it's always
  populated (§2, reconfirmed at 167-route scale in PR1.5) — RENKIN's own
  `--stock` file cross-check (`validate_stock_leaves`) stays completely
  independent of SynPlanner's own claim either way, matching the existing
  "tool's claim is structural input only, never trusted as verification"
  pattern. See §7 item 6 for the resolved parse-type decision.
- **Rule provenance** (`rule_id`/`rule_source`/`rule_key`): genuinely new
  information type, no existing RENKIN concept to map onto. **Resolved
  (§7, item 3)**: a SynPlanner-specific typed optional struct
  (`SynPlannerRuleProvenance`), not consumed by any finding/status logic
  in PR2, not generalized into a shared cross-adapter schema. PR1.5 also
  found real standard CLI usage never populates these fields at all (no
  `route_metadata` argument is ever passed by the shipped CLI).

## 4. Fixtures generated this round

`tests/fixtures/synplanner/v1.6.0/`:
- `route_1_two_step.json` — 2-step nested route (bromoethane → ethanol →
  chloroethane), one stock leaf, per-step rule provenance on both
  reaction nodes. `strict=True`, zero diagnostics.
- `route_3_full_fields.json` — 1-step route exercising every optional
  `RouteNode` field simultaneously (`meta`, `step_id`, `tree_node_id`,
  `rule_id`, `rule_source`, `rule_key`), non-sequential route ID (`7`,
  not `1`) to confirm the top-level key really is caller-supplied.
  `strict=True`, zero diagnostics.
- Malformed-route behavior (both `strict=True` and `strict=False`)
  reproduced and documented in `PROVENANCE.md`, not committed as a
  separate JSON artifact — matches this codebase's existing convention of
  hand-building malformed-input test cases in Rust unit tests rather than
  shipping a "malformed fixture" file (see AiZynthFinder's
  `structurally_corrupt_route_fails_loud_not_silently`).
- Determinism verified: re-running the exact same construction code
  produced a byte-identical `route_1_two_step.json`, checked before
  committing.
- **Not attempted**: a real MCTS-searched route (would require a
  downloaded trained policy/value checkpoint, explicitly out of scope
  this round) and a genuine `in_stock: null` case (per §2's finding, this
  real code path appears structurally incapable of producing one — not
  faked to fill a checklist).

**Phase 1 PR1.5 (2026-08-23) added real-MCTS-planning fixtures**, closing
the "not attempted" gap above:
- `real_planning_route_1step.json` / `real_planning_route_2step.json` —
  two real routes (aspirin, `synplanner-gps` preset), sliced verbatim from
  a genuine 167-route CPU-only MCTS search, run through the real
  `synplan planning` CLI end-to-end (not a hand-built `routes_dict`).
- `real_planning_export.manifest.json` / `real_planning_export.results.json`
  — the same two routes re-wrapped in the real `--export_routes` "public
  contract" shape, demonstrating the `directives.adapter == "synplanner"`
  detection signal (§3.2, §7 item 1).
- Full provenance, the atom-mapping audit methodology/results, and the
  RDKit forward-replay check: `real_planning_route.PROVENANCE.md`.
- Properties asserted directly against these fixtures (no SynPlanner/torch
  install required to run) by
  `scripts/tests/test_synplanner_real_route_fixture.py`.

## 5. What Phase 1 PR2+ look like

**PR2 done.** Delivered exactly as scoped below, plus one real end-to-end
confirmation the scoping didn't anticipate: a real MCTS-planning fixture's
reaction now measurably *passes* `bridge::forward::validate_step_forward`
(`status: "pass"`, `method: "declared_reaction_replay"`), not just
"doesn't report `MissingAtomMapping`" — SynPlanner is the first adapter in
this codebase whose routes are genuinely forward-evaluable end to end, per
CLI output on `real_planning_route_2step.json`. (One of Phase 0's own
hand-built fixtures, `route_3_full_fields.json`, correctly *fails* forward
validation instead — its `[CH3:1][CH2:2][OH:3]>>[CH3:1][CH2:2][Cl:3]`
SMIRKS reuses one atom-map number across an O->Cl identity change, which
isn't valid atom-mapped chemistry; that fixture was always documented as
schema-only, not chemically real, and the real engine catching it is
correct behavior, not a regression.)
- `src/bridge/synplanner.rs` (new file, AiZynthFinder-pattern recursive
  walker — `SynPlannerNode`, `synplanner_mol_to_route_node`,
  `normalize_synplanner_route`, `parse_synplanner_routes`).
  `RouteSource::SynPlanner` + `ReactionEvidence::SynPlannerReaction` (with
  the resolved `SynPlannerRuleProvenance` typed struct) added to
  `route_graph.rs`, stale "2-variant" comment fixed while there. A new
  `declared_smirks` match arm in `forward.rs` (as-declared orientation,
  same as AiZynthFinder/Syntheseus). New `AuditRouteFormat::SynPlanner`
  variant + `looks_like_synplanner_export` detection (the §3.2 heuristic,
  confirmed against real fixtures) + dispatch arm in `audit_route.rs`.
  Format allowlist updated in **both** `audit_route.rs` and `main.rs`, plus
  the two CLI usage strings in `main.rs` — exactly the duplication the
  design predicted.
  In-module unit tests (6 in `synplanner.rs`, 4 new in `audit_route.rs`)
  against real fixtures from both Phase 0 and Phase 1 PR1.5 (fixture-parity
  oracle pattern, matching every other adapter) — including one test that
  runs a real planning fixture through the *actual*
  `validate_step_forward` call, not just a standalone check. No Python/WASM
  logic changes made — both are thin passthroughs, confirmed unaffected by
  `cargo build`/`cargo test`.
  **Deliberately not supported yet, a tracked scope boundary**: the
  separate `--export_routes` "public contract" wrapper shape
  (`{target_smiles: [RouteNode, ...]}`) — only the internal
  `{route_id: RouteNode}` shape every committed fixture uses is recognized
  today.
- **PR3 done.** `tests/cross_tool_audit.rs`'s 3-tool tests extended to a
  4th ("CO -> C + O" described a fourth way via `SynPlannerNode`), with one
  real difference from the other two non-RENKIN adapters spelled out in the
  test itself: SynPlanner's step genuinely PASSES forward validation, not
  just "not_evaluable, never fail". 5 new CLI-level tests in
  `tests/audit_route_cli.rs` against the real Phase 0/PR1.5 fixtures,
  including one explicit synthetic (hand-authored, clearly labeled
  not-real) `in_stock`-missing case per §7 item 6's resolved allowance.
  Playground: a `--format synplanner` option, a "Load SynPlanner example"
  button (real `real_planning_route_2step.json` content embedded verbatim,
  3-language i18n), and a `?demo=synplanner` shareable link — all wired
  through the existing generic `AUDIT_EXAMPLES`/`?demo=` machinery, no new
  WASM/worker.js logic needed. New guide,
  `docs/guides/synplanner-audit-demo.md` (added to `mkdocs.yml` nav,
  cross-linked from the AiZynthFinder/Syntheseus guides and the
  reproducibility contract page) — demonstrating a genuine `PASS` verdict
  live, something neither of the other two adapter guides can show.
- **PR4**: version bump / release prep (separate authorization required,
  per this project's standing rule).
- Everything in the user's own "実施しない" list for *this* round
  (RouteDeltaGraph, MCTS, model training, benchmarking) stays out of
  scope for PR2/PR3 too — those are later phases (v0.35.0+) in the user's
  own longer-term roadmap, not part of the SynPlanner bridge itself.

## 6. Constraints reaffirmed

- SynPlanner's code was never copied — only its public, real, running
  behavior was observed (real package install, real function calls, real
  source reading of the installed package). No `chython` object crosses
  into RENKIN's Rust core; the adapter (§3) only ever consumes the
  already-exported JSON, the same boundary every other adapter respects.
- Algorithm/format attribution: SynPlanner's own README/CHANGELOG citations
  (Westerlund et al. for quality scoring, Gilmullin et al. for clustering)
  are recorded in the competitive baseline doc, not reproduced or claimed
  as RENKIN's own.
- Base RENKIN install stays untouched by this investigation — the 1.6GB
  SynPlanner venv used to generate this round's fixtures was disposable
  and has already been removed (disk headroom was a live, explicit
  constraint this round: ~14GB free before install, confirmed reclaimed
  back to ~15GB after cleanup).
- No version bump, merge, tag, publish, issue close, or upstream contact
  this round.

## 7. Open questions for Phase 1 PR2 (resolution record — update in place, never delete)

1. **Format-detection signal validity against real planning output**
   (§3.2): derived from hand-constructed fixtures only. Needs
   re-verification against a real MCTS-searched route's actual top-level
   JSON shape (which itself requires the model-download step this round
   explicitly deferred) before PR2 ships the detection code as final.

   **Resolved in Phase 1 PR1.5 (2026-08-23)**: confirmed against a real
   167-route MCTS search (aspirin, `synplanner-gps` preset, CPU-only —
   see `tests/fixtures/synplanner/v1.6.0/real_planning_route.PROVENANCE.md`).
   The internal `write_routes_json` top-level shape (`{route_id:
   RouteNode}`) matches Phase 0's hand-built-fixture finding exactly.
   **But a better, previously-unknown signal exists**: running the real
   CLI with `--export_routes` produces a *separate*, explicitly versioned
   "public route-export contract" (`manifest.json` +
   `results.json.gz`, `ROUTE_EXPORT_SCHEMA_VERSION = "synplan-routes/1"`
   in `synplan/mcts/search.py`) whose manifest carries
   `directives.adapter == "synplanner"` — an unambiguous, first-class
   detection signal, strictly better than the structural heuristic §3.2
   proposed. **Recommendation for PR2**: prefer sniffing for a
   `manifest.json`+`results.json.gz` (or an already-decompressed
   equivalent) pair with that directive when available, and keep §3.2's
   structural heuristic only as the fallback for the bare `RouteNode`
   shape (e.g. a single `mapped_routes_N.json` handed to RENKIN directly,
   without its manifest). §3.2's original heuristic itself checks out
   against real output too — not superseded, just not the best available
   signal. See `real_planning_export.manifest.json` /
   `real_planning_export.results.json` fixtures.

2. **Does the exported reaction `smiles` field reliably retain atom maps
   from a real planning run**, the way it did in this round's
   hand-constructed fixtures (`format(rxn, "m")`)? Not confirmed against
   real search output. If yes, SynPlanner routes might actually become
   forward-evaluable (unlike AiZynthFinder/Syntheseus) — a materially
   different outcome worth re-checking before assuming `not_evaluable` is
   the final answer for every SynPlanner step.

   **Resolved in Phase 1 PR1.5 (2026-08-23)**: yes, confirmed with real
   evidence, not just re-asserted. Across all 317 real reaction nodes in
   the 167-route real search: 100% carry non-empty atom mapping, 0 have
   duplicate map numbers on either side, 0 have orphan product-side map
   numbers, and — despite `route_cgr.py`'s own docstring describing the
   default export path as using non-reconciled "per-step-local"
   numbering — 100% (150/150) of real cross-step boundaries carry
   byte-identical mapped SMILES between a step's product and the next
   step's reactant. Forward-replay was tested directly with RDKit
   (`ReactionFromSmarts` + `RunReactants`, independent of RENKIN's Rust
   code) on 3 representative reactions and reproduced the target exactly
   in every case. **Category A** per this round's own classification:
   real planning reactions have valid, usable atom maps and are genuinely
   forward-evaluable — for this preset/target/config. RENKIN's existing
   `has_atom_mapping` gate would pass all 317 reactions unchanged, no new
   forward-validation logic needed. Scope caveat: one target (aspirin),
   one preset (`synplanner-gps`), `--reconcile-mapping` not exercised —
   not yet a claim about every preset/target/flag combination. Full
   detail and the audit numbers: `real_planning_route.PROVENANCE.md`;
   the same properties are asserted directly against the committed
   fixtures by `scripts/tests/test_synplanner_real_route_fixture.py`.

3. **`ReactionEvidence` extension for rule provenance**
   (`rule_id`/`rule_source`/`rule_key`): extend the shared enum (usable by
   any future adapter with similar data), or keep it SynPlanner-specific,
   or drop it entirely as out-of-scope for the audit pipeline (which
   doesn't currently have any finding that would consume rule provenance)?
   Not resolved this round — a real design decision, not an implementation
   detail.

   **Resolved (design decision, 2026-08-23)**: keep it SynPlanner-specific,
   as a typed optional struct, not a shared-enum extension:
   ```rust
   struct SynPlannerRuleProvenance {
       rule_id: Option<String>,
       rule_source: Option<String>,
       rule_key: Option<String>,
   }
   ```
   Not consumed by any finding or status determination in PR2 — carried
   for forward-compatible provenance only. Generalizing to a
   cross-adapter provenance schema (usable by AiZynthFinder/Syntheseus
   too) is deferred to a separate future design, not attempted in PR2.
   Note also (§2/PROVENANCE.md, PR1.5): real standard `synplan planning`
   CLI usage never populates these fields at all (no `route_metadata` is
   ever passed by the shipped CLI) — so `SynPlannerRuleProvenance` will be
   `None`/absent for real CLI-produced routes today, and only populated
   when a caller of SynPlanner's Python API explicitly supplies
   `route_metadata` itself.

4. **`meta`/`step_id`/`tree_node_id` treatment**: opaque pass-through bag,
   or dropped entirely? Leaning toward "keep as opaque, never interpreted"
   per this codebase's existing forward-compatibility convention, but not
   finalized.

   **Resolved (design decision, 2026-08-23)**: read-and-discard, not
   carried anywhere in the audit report. Concretely for PR2: unknown
   fields on a `RouteNode` object must be *accepted* (parsing must not
   fail on their presence), but `meta`/`step_id`/`tree_node_id` are never
   copied into the report as opaque JSON, and `step_id`/`tree_node_id`
   must never be confused with or substituted for RENKIN's own internal
   node identifiers. If a real, concrete need for one of these surfaces
   later, add it additively then — forward compatibility here means "an
   unknown field never breaks parsing," not "every field is preserved."

5. **Route-ID provenance**: thread the top-level `route_id` key through as
   an opaque string on `AuditReport`, or discard it? No existing RENKIN
   concept for "the source tool's own route identifier" — worth checking
   whether AiZynthFinder/Syntheseus adapters have quietly wanted this too.

   **Resolved (design decision, 2026-08-23)**: use it only as parse-time
   defect context in PR2 (e.g. naming which route a parse failure came
   from in an error message) — do **not** add it to `AuditReport`'s
   public schema yet. Source-tool route-ID provenance isn't a
   SynPlanner-specific problem (AiZynthFinder/Syntheseus routes have the
   same shape of question), so it will be addressed later as one shared
   `source_route_id`-style design covering every adapter, not designed
   piecemeal inside the SynPlanner adapter.

6. **`in_stock` optionality**: §2/§3.3 found the real export path always
   populates it, but the adapter's parse-time type should still be
   `Option<bool>` defensively. Confirm this doesn't create a dead code
   path that never actually exercises the `AmbiguousLeafStatus` finding
   for SynPlanner — if so, is a synthetic test case (mirroring
   AiZynthFinder's explicitly-labeled non-real Fixture C) warranted for
   PR2's own test suite?

   **Resolved (design decision, 2026-08-23; reconfirmed against real
   output in PR1.5)**: parse type stays `Option<bool>` as planned —
   `Some(true)` = source planner claims a stock leaf, `Some(false)` = not
   stock, `None` = `AmbiguousLeafStatus`, entirely independent of
   RENKIN's own `--stock` file cross-check either way (the tool's claim
   is structural input only, never trusted as verification — the existing
   pattern for every adapter). PR1.5 reconfirmed at 167-route scale that
   the real exporter never actually produces `None` in practice. A
   synthetic (missing-field) negative test case is permitted for PR2's
   own test suite to exercise the `None` branch, provided it is clearly
   and explicitly labeled as *not* real SynPlanner output (mirroring
   AiZynthFinder's Fixture C convention) — not added this round, since
   there is no `Option<bool>` parser yet to test it against.
