Skip to Content

Measurement Bridge

The measurement bridge is the wiring between the measurement core and the reporting surfaces. It turns a finished diagnostic into the per-skill, per-domain, and macro-tile statuses that every teacher report, parent PDF, board summary, and multi-school rollup reads. It is a server-side projection hung off diagnostic finish, and it introduces no new routes.

Before the bridge shipped, finishSession wrote per-sub-skill θ to student_assessment_result and stopped. Nothing wrote evidence_row, nothing called the skill-status recompute, and nothing wrote macro_domain_status_snapshot at all, so every reporting, parent, board, and multi-school macro surface read an empty table in production. The bridge closes that gap by emitting evidence and snapshots at the moment a diagnostic finishes.

The gap the bridge closes

The bridge closes two seams left open after a diagnostic finished.

  1. θ results were never turned into an evidence_row. The skill-status engine had no per-sub-skill evidence to recompute from.
  2. The decision engine wrote each domain_status_snapshot but nothing aggregated the 6 macro tiles, so the macro table stayed empty.

θ stays exactly where it was: persisted on student_assessment_result and consumed by practice seeding. The bridge is a parallel emission, not a replacement. It does not move θ or re-score anything.

The trigger chain

The shipped flow runs in this shape:

finishSession (modules/session/diagnostic.orchestrator.ts) ├─ (in the finish tx, first-finish gated) writeScreeningEvidence → evidence_row {correct,total} └─ (AFTER the tx commits) projectBridge (modules/sse/bridge/diagnostic-bridge.ts) ├─ recomputeForEvidence × N sub-skills → skill_status_snapshot + domain_status_snapshot └─ recomputeMacroForStudent ONCE → macro_domain_status_snapshot (6 tiles + GLOBAL)

Key facts:

  • The bridge runs after the finish transaction commits, not nested inside it. There are two reasons. The evidence_row must be durable before recomputeForEvidence re-reads it, and the finish lock on (org, profile.id) must not span the skill-status lock on (org, student, window).
  • The whole sequence is synchronous and in-process. There is no queue or broker, because the pilot runs at roughly 30 schools.
  • A crash between the commit and the bridge self-heals on re-finish, because every write is version-pinned idempotent.
  • The macro pass is debounced: N sub-skills drive N skill and domain recomputes, but exactly one macro pass runs over the latest committed feeders.

What evidence_row carries (counts, not θ)

The skill-status engine consumes a 3-item screening result per sub-skill, not θ. Under the v5 skill screening rule, 3 items resolve as 3/3 = no need, 2/3 = monitor, and 0-1/3 = training need. The bridge writes that input directly:

  • evidence_row.value = { correct, total, sourceSessionId } with evidenceType = "screening_score".
  • correct is summed from the server-graded inputResponses.isCorrect over the distinct scored items. It is never re-derived from θ, because a second scoring path would be a V-6 hazard.
  • studentId = StudentProfile.id end-to-end.
  • The idempotency backstop is the first-finish gate in finishSession. The evidence_row table itself has no UNIQUE constraint.
  • continuous windows produce no screening evidence. screeningCycleToEvidenceWindow returns null and the bridge is skipped. Screening evidence is BOY, MOY, or EOY only.

The macro projection (6 tiles + GLOBAL)

writeMacroSnapshot lives in repository/snapshot-writer.ts, the single skill-status write path that the lint enforces. A pure engine/macro-rollup.ts rollupMacroTile aggregates the latest committed domain_status_snapshot emissions per feeder into the 6 tiles plus GLOBAL.

The rollup is worst-case-wins and never numeric (FR-MDR-1). The precedence ladder is Severe, then Below, then Approaching, then Partial, then Contextual, then Meets, then Not_Assessed. Not_Assessed is never zero and never an average. For the full rollup mechanics see The Decision Engine.

The GLOBAL meta row is written per student, with isMetaRow=true. It is the cross-cutting rollup over all feeders and is consumed by Profile_Rules_v2 PR2-00 DATA_INCOMPLETE. It is a meta row, never a dashboard tile.

Two more facts about the macro snapshot:

  • feederStatuses carries [{domainId, domainStatus, dsmRuleRef, engineSnapshotRef}] so a reader can drill down into which domains drove a tile.
  • computedAt and occurredAt are frozen to session.finishedAt (V-6), so the macro rows replay byte-identically.

The id-key contract (StudentProfile.id vs User.id)

The three skill-status snapshot tables, plus evidence_row and student_assessment_result, key studentId on StudentProfile.id, not on User.id. The downstream plan and alert chain tables stay on the User.id id-space. So inside a single module a snapshot read is on StudentProfile.id while the assignment or alert read beside it is on User.id. This is the highest-confusion fact for any future reader: do not assume the two id-spaces are interchangeable.

The id-key sweep fixed every reader that read a snapshot on User.id, in two tiers:

  1. The named macro consumers: reporting, board PDF, multi-school, parent PDF, and the profile module’s readProfileInputContext.
  2. The decision-pipeline snapshot readers: bundle, rti, data-room, ism, and workflow. Each resolves User.id to StudentProfile.id for its snapshot reads only.

The boundary (tracked as Q-BRIDGE-5, deferred) is that the chain tables (student_profile_assignment, bundle_assignment, bundle_recommendation, student_tier_status, acute_regression_alert, student_scaffold_assignment, monitoring_status_review, data_room_*, decision_packet_*) stay on the self-consistent User.id id-space that the profile, bundle, rti, and monitoring chain threads end-to-end. Normalizing those table id-spaces to StudentProfile.id is a single coherent follow-up, not split piecemeal.

The mismatch was latent because integration tests seed in a per-file database where User.id equals StudentProfile.id by auto-increment coincidence. The ids only diverge once two or more users are created before the student. The wide-net proving test is __tests__/id-key-sweep.integration.test.ts.

Append-only, idempotency, and V-6 replay

macro_domain_status_snapshot and evidence_row are append-only .create() rows, enforced by lint-no-update-on-audit-tables.ts. Macro writes go only through snapshot-writer.ts, enforced by lint-sse-snapshot-single-write-path.ts.

The shipped idempotency UNIQUE is @@unique([studentId, key, evidenceWindow, ruleVersion, seedVersion]), plus a new evidenceWindow column. The old @@unique([studentId, key, computedAt]) de-duped nothing, because computedAt defaults to now() and never collides. writeMacroSnapshot does a pre-insert findUnique under the advisory lock, so a same-version re-trigger is a no-op. A re-finish writes nothing new.

Empty until a diagnostic finishes

The macro tables are empty in a fresh org and stay empty until a diagnostic finishSession runs for a student. Until then, the reporting, board, and parent surfaces return a dataIncomplete or Not_Assessed shape with HTTP 200. They fail closed and never return 404. An all-Not_Assessed report before any diagnostic has finished is expected behavior, not a fault. This ties into the existing dataIncomplete: true behavior described in Reporting & Parent PDF.

Last updated on