Skip to content

Rust module diverges from the TS transform in 12 verified places (wire bytes, fold decisions, persisted state) #381

Description

@iceteaSA

Summary

transform_mode is a runtime toggle over the same database and the same session, so the Rust module and the TypeScript plugin are two implementations of one contract. A slice-by-slice comparison found 12 places where they produce different observable behaviour for the same input.

Each item below was verified by opening both sides. Where I could construct the concrete divergent input, it is given. Findings I could not substantiate were dropped — see "What I checked and rejected" at the end, because the rejections are informative about where the boundaries actually are.

The meta-finding is at the bottom and may matter more than any single item: the differential goldens pass, and several of these divergences sit in cases no golden exercises. A passing cargo test does not currently imply parity.


Class A — wire-byte divergences (prompt cache busts on mode flip)

A1 — Legacy truncation counts UTF-16 units in TS, Unicode scalars in Rust

  • TS packages/plugin/src/hooks/magic-context/decay-render.ts:104content.slice(0, 420) / content.slice(0, 1_200). String.prototype.slice indexes UTF-16 code units.
  • Rust crates/mc-module/src/decay_render.rs:186content.chars().take(max); counts Unicode scalar values.

Input "U:\n" + "a".repeat(416) + "😀b" — 421 scalars, 422 UTF-16 units:

cut at result
TS unit 420 splits the emoji, emits an unpaired high surrogate
Rust scalar 420 emoji intact

Different m[0] bytes for the same legacy compartment. The Rust docstring already acknowledges this ("Char-boundary safe (vs the TS UTF-16 slice…)"), so the divergence is known at the source — it just isn't reconciled or covered by a golden. crates/mc-core/testdata/decay-golden.json contains zero non-BMP characters and never exercises the 420/1200 boundary.

A2 — Reasoning sentinels drop cache metadata and preserve the original type

  • TS packages/plugin/src/hooks/magic-context/sentinel.ts:105-121makeSentinel forces type: "text" and copies cache_control / cacheControl from the original part.
  • Rust crates/mc-module/src/transform.rs:13037-13052empty_reasoning_sentinel emits only type (preserved from the original, defaulting to "reasoning") plus thinking or text. No cache metadata copy.

Clearing {type:"reasoning", text:"…", cache_control:{type:"ephemeral"}} gives TS {type:"text", text:"", cache_control:{…}} and Rust {type:"reasoning", text:""}. Three differences: the type field, the key, and the dropped cache-control boundary. This one is doubly relevant because it changes where a provider cache breakpoint lands.

A3 — Edit-marker canonicalises JSON key order; TS preserves insertion order

  • TS packages/plugin/src/hooks/magic-context/edit-marker.ts:53-62applyEditMarkerToInput mutates the input object in place; JS preserves insertion order on serialise.
  • Rust crates/mc-module/src/selection.rs:609-624canonical_json sorts object keys ("deterministic bytes across passes").

{"z":…,"filePath":…,"content":…} serialises as z,filePath,content under TS and content,filePath,z under Rust.

Caveat, stated because it is the exact trap that killed one of my other candidates: I confirmed the canonicalisation at selection.rs, but did not prove the canonicalised payload reaches the wire rather than remaining a frozen store artifact that is re-rendered later. If the render path re-serialises through the host's own encoder, this is a non-issue. Worth a maintainer's five seconds before anyone acts on it.


Class B — decision divergences (same input, different fold/reclaim outcome)

B1 — Completed tool-arc fencing moves in opposite directions

  • TS packages/plugin/src/hooks/magic-context/protected-tail-boundary.ts:371-372end = Math.min(protectedTailStart, resOrdinal + 1). Always forward: extend to include the whole arc.
  • Rust crates/mc-module/src/boundary.rs, fence_boundary_for_completed_tool_arcsif min_invocation < floor { max_result + 1 } else { min_invocation }. Forward only when the invocation is below the publication floor; otherwise backward, excluding the entire arc.

Both honour arc atomicity, but they resolve the same crossing in opposite directions. With offset=1, protected_tail_start=5, a completed arc at inv=2/res=4 and a head-cap end of 3: TS yields 1..5 (arc retained), Rust yields 1..2 (arc excluded). The historian receives a different chunk, so different compartments are durably persisted depending on which mode was active.

B2 — Reclaim can rewrite an in-flight tool call

  • TS packages/plugin/src/hooks/magic-context/tool-drop-target.ts:284-291partHasCompletedResult requires state.output to be a string or status === "error". The three reclaim methods (:386, :399, :414) each return "incomplete" when !entry.hasResult.
  • Rust crates/mc-module/src/selection.rs:707-728expand_arc iterates arc.call_inputs and emits a ReductionDecision for each call block before it touches result_ids. group_arcs (:329) initialises result_ids: Vec::new(), the active_arcs filter checks only !reduced && !provider_executed && !reasoning_ineligible, and the sole result_ids.is_empty() guard in the file (:833) is scoped to select_tool_dedup.

So an arc with call inputs and no result yet still yields a decision that rewrites the call block. TS explicitly refuses. I'd rank this the most serious item here: it is a correctness problem in its own right, not merely a mode-flip inconsistency. I have not constructed a live repro proving such an arc reaches selection in practice — but TS guards against it in three separate places, which is reasonable evidence that it is reachable.

B3 — Caveman eligibility is narrower in Rust

  • TS packages/plugin/src/hooks/magic-context/caveman-cleanup.ts:111-118 — eligible when type === "message" && status === "active" && tagNumber <= protectedCutoff && byteSize >= minChars.
  • Rust crates/mc-module/src/transform.rs:6241-6260 — additionally requires !synthetic, is_tail(...), role ∈ {user, assistant}, and matches!(&block.wire.kind, CkKind::Text{..}) — exact Text only.

A large mixed assistant message (text plus tool blocks) is compressed by TS via its text target and skipped entirely by Rust. Different compression depth persists.

B4 — Channel-1 reminder spans are stripped from different surfaces

  • TS packages/plugin/src/hooks/magic-context/tail-hygiene-walk.ts:632stripChannel1ReminderSpans is applied to rawOutput only, i.e. tool output.
  • Rust crates/mc-module/src/tail_hygiene.rs:588 — applied to every user/assistant Text block before token measurement.

User text ending in a <system-reminder> span counts toward T/U under TS and is stripped under Rust. Since the nudge bands are thresholds on U/T (0.20/0.40/0.60, plus Channel 2 at 0.75), the same conversation can sit in different bands.

B5 — Context-limit fallback differs by 72k

  • TS packages/plugin/src/hooks/magic-context/event-resolvers.ts:17,86DEFAULT_CONTEXT_LIMIT = 128_000, used as the final fallback.
  • Rust crates/mc-module/src/transform.rs:5818effective_context_limit_tokens falls back to 200_000.0.

With no metadata, no detected limit and no geometry, 100k of usage reads as 78.1% under TS and 50% under Rust — different side of every threshold band.


Class C — persisted-state divergences (different rows written)

C1 — Content edits leave the mural cue stale

  • TS packages/plugin/src/features/magic-context/memory/storage-memory.ts:1036-1082 — clears mural_cue, mural_cue_hash, mural_cue_at (and resets mural_cue_rejection_count) when content changes.
  • Rust crates/mc-store/src/lib.rs:11168-11175update_memory_content sets only content, normalized_hash, updated_at, shareable = 0, classified_at = NULL.

mc_memories carries all four mural-cue columns (added at lib.rs:2276-2279), so this is a genuine omission rather than an absent concept: a cue rendered from deleted text survives the edit.

Scope correction worth recording: my first pass had this as six missing invalidations, including memory_embeddings and memory_verifications. That was wrong — mc-store contains zero references to either table, so it does not own that state and cannot be skipping its cleanup. Only the mural-cue columns are a real gap.

C2 — Note updates: Rust trims, versions, and rejects empty; TS does none of it

  • TS packages/plugin/src/features/magic-context/storage-notes.ts:367-400updateNote stores updates.content verbatim. Zero references to trim or status_version.
  • Rust crates/mc-store/src/lib.rs:13144-13175update_note_cas requires matching status/status_version, applies content.map(str::trim), returns Conflict on empty trimmed content, and increments status_version.

Updating a note to " spaces " stores the spaces under TS and the trimmed form under Rust; all-whitespace content is accepted by TS and rejected by Rust. Concurrent-write semantics differ too — a race that succeeds under TS conflicts under Rust.

C3 — Smart-note compiled metadata is dropped

  • TS packages/plugin/src/tools/ctx-note/tools.ts:327-347,404-416 — persists compiled_provider, compiled_config, compiled_at, compile_status alongside the condition.
  • Rust crates/mc-store/src/lib.rs:4199-4208NoteWriteInput carries surface_condition and no compiled fields at all.

A smart note written under Rust authority is stored uncompiled, so the dreamer's evaluation path sees a different note than the same write under TS would produce.

C4 — Merge picks a different canonical row

  • TS packages/plugin/src/tools/ctx-memory/tools.ts:911-985 — looks for a row matching the merged content hash, may insert a new canonical row via insertMemoryIdempotent, and supersedes every source row other than the canonical one.
  • Rust crates/mc-module/src/lib.rs:14164Some((ids[0], ids[1..].to_vec())); merge_memories rewrites the target row in place and supersedes only the remaining ids.

ctx_memory(action="merge", ids=[1,2], content="C") with active memories 1 and 2: TS can create id 3 and supersede both; Rust rewrites id 1 and supersedes only id 2. Resulting ids, merged_from lineage, and any later lookup by id all differ.


The meta-finding: goldens pass, but coverage is narrower than the contract

There is a real differential harness — crates/mc-module/src/differential_goldens.rs ("TS emits fixtures, Rust consumes them in-process") plus ~40 fixtures under crates/mc-module/testdata/. It passes: I built the module at da47b516 and got 1150 passed / 0 failed / 4 ignored.

Several divergences above nonetheless sit in cases no fixture exercises. The clearest is A1: decay-golden.json has no non-BMP characters, so the one truncation case where UTF-16 and scalar counting disagree is exactly the case not tested.

If only one thing comes out of this issue, the highest-leverage fix is probably adding the adversarial cases to the existing goldens — astral Unicode at the truncation boundary, a resultless tool arc in selection-golden.json, a mixed text+tool message in the caveman fixtures. That converts this class of drift from "found by reading" to "caught by CI", which is worth more than any individual fix below.


What I checked and rejected

Recording these because the rejections mark where the real boundaries are, and because two of them are traps a future reader will hit in the same way I did:

  • Drop placeholder [dropped] vs [dropped §N§] — not a divergence. mc-store stores the canonical [dropped] marker; crates/mc-module/src/transform.rs:12496-12509 maps it through the tag overlay to [dropped §N§] at render time. Store layer vs wire layer. Arguably the better design, since the stored form doesn't bake in a tag number.
  • memory_embeddings / memory_verifications not cleaned on content update — not a divergence. mc-store has zero references to either; it does not own that state.
  • <covered-system-messages> in Rust m[0] but not TS — real structural difference, but intentional: it has a passing golden (covered-system-transform-golden.json) and ARCHITECTURE.md documents the module "absorbing covered system-role messages into the m0 baseline".
  • ctx_memory(scope:"global") routing — specific to a downstream fork that removed the external-memory backend; not an upstream concern.
  • Pressure-backstop constants, min_chunk_tokens — checked, no divergence. All four backstop constants match exactly (500, 0.15, 0.20, >40).

Provenance

Found by comparing the two implementations slice by slice, then verifying every surviving claim by opening both sides. I was initially sceptical that a shipped port could diverge in a dozen places and predicted most would fall — that prediction was wrong, and the arithmetic is worth stating plainly: of 15 candidates, 12 held up, 1 was intentional, 1 was fork-specific, and 1 was a layer-confusion error on my part. "Upstream ships it" implies the goldens pass; it does not imply parity.

Happy to send PRs. The cheap mechanical ones are B5 (one constant), C1 (four columns), and A2 (a field copy). B2 wants a maintainer's judgement on whether the guard belongs in active_arcs or in expand_arc, and B1's direction question is genuinely a design call rather than an obvious bug.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions