Skip to content

docs(adr): ADR 0200 — reconcile channel-weight measurement across the two active lines - #574

Open
seonghobae wants to merge 6 commits into
mainfrom
docs/adr-0200-weight-reconciliation
Open

docs(adr): ADR 0200 — reconcile channel-weight measurement across the two active lines#574
seonghobae wants to merge 6 commits into
mainfrom
docs/adr-0200-weight-reconciliation

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

main and docs/customer-master-scope-adr carry ADR 0145 with opposite decisions: the scope line estimates fusion weights with fast-mlsirm and deleted the hand-picked constants (fail-closed product paths), while main records the proposal as Rejected, authorizes zero anchor methods, stubs the estimation command — and retains the hand-picked constants "for compatibility". Same ADR number, contradictory decisions, divergent lineage_channel_weight schemas: an exact-head contradiction.

ADR 0200 proposes the reconciliation, engaging main's methodological critique on the merits instead of overriding it:

  1. The operator directive governs both lines — no hand-picked fusion weight reaches any product path; fail-closed with an estimate-first next action becomes the single behavior; compatibility constants are retired.
  2. Expected-information weights w_j ∝ E_θ[a_j² P_j(θ)Q_j(θ)] over the fitted multilevel latent distribution replace discrimination-proportional weights — integrating Birnbaum information's θ-conditionality rather than ignoring it (van der Linden, 2005); non-converged fits rejected via fast-mlsirm's official diagnostics.
  3. Anchor honesty + TEPP gate: sets activate but carry anchor_method_code='unanchored_internal_structure' and full provenance until a TEPP-anchored criterion-validity gate exists; a failed gate retires the set. Amends ADR 0003's scope boundary explicitly (ADR-first, as main's 0145 required).
  4. Schema union: (channel_set_code, channel_code) PK + main's per-run provenance columns, one migration with rollbacks for both predecessors.
  5. Queued judge scoring via the post_content_queue/worker idiom — resumable, rate-governed, no bulk synchronous provider calls (per the operator's 2026-08-24 directive after the 400-pair run saturated the shared gateway), satisfying issue Activate the optional lineage LLM channel through a bounded asynchronous rebuild #289's bounded-durable-worker requirement.
  6. Sequencing: ADR lands on both lines; implementation main-first (the shared dev environment is main-line now); scope rebases; corpus re-import then re-estimation on the merged head.

Docs only — no code changes in this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J


Open in Devin Review

…ross lines

The two active lines carry ADR 0145 with opposite decisions (estimation
active + constants deleted vs. rejection + constants retained), on
divergent lineage_channel_weight schemas. ADR 0200 keeps the operator
directive (no hand-picked weight anywhere), upgrades the estimator to
expected-information weighting over the fitted latent distribution
(answering the theta-conditionality critique), adds an anchor-honesty
label plus a TEPP criterion-validity gate (answering the
criterion-validity critique, amending ADR 0003 explicitly), merges the
two schemas as a union, and moves llm pair scoring to the durable
Valkey queue idiom per the operator's no-bulk-synchronous-LLM
directive and issue #289.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 29 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1eba8d22-fe54-465b-bff4-4e973f1bd2d2

📥 Commits

Reviewing files that changed from the base of the PR and between 7de3e17 and e60d1ba.

📒 Files selected for processing (9)
  • backend/app/lineage_ingestion.py
  • docs/adr/0200-channel-weight-reconciliation.md
  • lineageweave/channel_weight_estimation.py
  • migrations/0200_channel_weight_schema_union.sql
  • migrations/rollback/0200_channel_weight_schema_union.sql
  • scripts/estimate_channel_weights.py
  • tests/test_channel_weight_estimation.py
  • tests/test_estimate_channel_weights_script.py
  • tests/test_lineage_ingestion.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Implementation note from cross-session coordination: a parallel session is adding a Valkey-backed batch-job registry to contextual-orchestrator (batch_job_registry_valkey_url, opt-in) and moving /api/ask behind a Valkey job queue (#572). Point 5's queued judge scoring can therefore take either of two compatible shapes at implementation time: (a) LineageWeave-side per-pair jobs drained by a bounded worker (the shape written here), or (b) submission through the orchestrator's batch_routing_jobs API — whose jobs would survive orchestrator restarts once that registry lands — with LineageWeave still persisting per-pair scores durably as they return (the resumability requirement stays on our side either way). The ADR's contract (no bulk synchronous calls, durable per-pair scores, fit only on a complete run) is unchanged by the choice; the implementation PR should pick whichever is live by then and say so.

@seonghobae
seonghobae enabled auto-merge (squash) August 24, 2026 08:05
@opencode-agent
opencode-agent Bot disabled auto-merge August 24, 2026 08:43
@seonghobae
seonghobae enabled auto-merge August 24, 2026 09:12
@github-actions
github-actions Bot disabled auto-merge August 24, 2026 09:25
@seonghobae
seonghobae enabled auto-merge August 24, 2026 11:50
…t 2, no activation)

Replaces main's unconditional-refusal estimation stub with the full
estimator ADR 0200 specifies: MLS2PLM over dichotomized channel scores
with multilevel cluster intercepts, weights = normalized EXPECTED item
information over the fitted latent distribution, computed on the fitted
person parameters with the package's own predict_proba -- answering the
theta-conditionality critique in main's ADR 0145 rejection instead of
ignoring it. Non-converged fits are rejected outright
(convergence_status must be 'converged'). Method code:
mls2plm_expected_information.

NOTHING ACTIVATES: the product loader's authorized anchor set remains
empty, so persisted-weight activation still fails closed exactly as
main's ADR 0145 requires -- this stage lands the estimator and its
parameter-recovery evidence only. The demo generative design's follow
probabilities are re-declared (0.80/0.72/0.66) so the fixture estimate
preserves the designed A-100 demo fork under expected-information
weighting; the estimate over that design converges and recovers the
declared ordering.

Supersedes main's test_unanchored_channel_scores_never_run_a_fit by
design: running the fit is now permitted, activation is not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J
seonghobae and others added 2 commits August 25, 2026 00:55
…nt 4, no activation)

Migration 0200 unions the two lines' lineage_channel_weight schemas:
primary key (channel_set_code, channel_code) -- one persisted set per
active-channel combination -- carrying main's full per-run provenance
contract and integrity constraints. Validated live against BOTH
predecessor shapes (main's 0135 provenance table and the
customer-master line's 0135+0136 set table), replay-idempotent per the
ADR 0166 window, with a rollback that restores main's single-set
shape. Pre-provenance rows are deleted, not backfilled with invented
provenance -- the loader refuses them either way and re-estimation is
the operator's next action.

The loader becomes set-aware (exact active-channel match of exactly
one set) while keeping every provenance-integrity gate; a pre-0200
schema is probed via the catalog (never a failing statement that would
abort the caller's transaction) and read as the single implicit
deterministic set. ACTIVATION IS UNCHANGED: the authorized anchor set
stays empty, so every loaded vector is still refused.

The operator script becomes the full estimator front-end: fetch on one
short-lived connection, no connection held while fitting, persist with
full provenance (fresh run uuid, installed estimator version, honest
anchor_method_code=unanchored_internal_structure, reproducible
source-snapshot digest over the ordered sampled rows, knowledge cutoff
= max sampled created_at) on a fresh connection. Its report names
activation as blocked until an anchor is authorized. The llm channel
is deliberately absent -- bulk synchronous provider calls are banned;
llm scoring arrives with the queued worker (ADR 0200 point 5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J
feat(estimation): ADR 0200 points 2+4 — expected-information estimator, schema union, provenance persistence (no activation)

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review

# produced), using the package's own item response function
# (predict_proba) rather than a re-derived one (van der Linden,
# 2005, on expected/target information as the design quantity).
probabilities = numpy.asarray(predict_proba(result.params, factor_id), dtype=float)

@devin-ai-integration devin-ai-integration Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 predict_proba receives the item-to-factor map, not person parameters

predict_proba(result.params, factor_id) (lineageweave/channel_weight_estimation.py:207) passes factor_id -- the all-zeros item-to-latent-dimension map of length len(channels) built for fit(). The docstring says expected information is averaged over the fitted person parameters, which would need the fitted theta, not factor_id. If the second argument is treated as person parameters, probabilities are evaluated at three placeholder points rather than the fitted latent distribution, diverging from the stated design. Runs only when fast_mlsirm is importable; confirm against its actual signature.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +208 to +209
if probabilities.shape[1] != len(channels):
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 1D probabilities would raise instead of failing closed

The guard reads probabilities.shape[1]. A 1D return from predict_proba makes this raise IndexError and escape, unlike every other path here which returns None. Reachable only with fast_mlsirm installed and depends on its return shape.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +118 to 138
all_rows = await conn.fetch(
f"select {set_column_sql} as channel_set_code, "
"channel_code, weight_value, estimation_run_id, "
"estimation_method_code, estimator_version, anchor_method_code, "
"source_snapshot_sha256, sample_pair_count, knowledge_cutoff "
"from lineage_channel_weight"
)
sets: dict[str, list] = {}
for row in all_rows:
sets.setdefault(row["channel_set_code"], []).append(row)
rows = next(
(
candidate
for candidate in sets.values()
if {row["channel_code"] for row in candidate} == active_channels
),
[],
)
persisted = {row["channel_code"]: float(row["weight_value"]) for row in rows}
if not persisted or set(persisted) != active_channels:
return None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Set-matching and migration idempotency check out

load_estimated_channel_weights groups rows by channel_set_code and activates only a set whose channel codes exactly equal active_channels, else None, so no partial vector mixes runs. The catalog probe avoids a failing statement on pre-0200 schemas, and its literal default matches migration 0200's. Across both predecessors the delete guard empties rows before NOT NULL provenance columns are added, and the PK swap and constraint re-assertion are idempotent.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@opencode-agent
opencode-agent Bot disabled auto-merge August 24, 2026 16:51
@seonghobae
seonghobae enabled auto-merge August 24, 2026 17:04
@github-actions
github-actions Bot disabled auto-merge August 24, 2026 17:10
estimate_fixture_channel_weights,
simulate_fixture_pair_scores,
)
from lineageweave.models import Record
…verges

fast-mlsirm's FitConfig default (max_iter=1000) is tuned against its
GPU/f32 kernel; the f64 CPU fallback -- the only path available on any
CI runner, since none expose a GPU -- needs materially more EM
iterations to reach the same optimum at full precision (observed up to
~1850 on this module's own recovery fixture). Confirmed locally by
forcing rust_device="cpu": both channel-weight recovery tests reliably
hit max_iter_reached at the old default and reliably converge at 3000.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +118 to 124
all_rows = await conn.fetch(
f"select {set_column_sql} as channel_set_code, "
"channel_code, weight_value, estimation_run_id, "
"estimation_method_code, estimator_version, anchor_method_code, "
"source_snapshot_sha256, sample_pair_count, knowledge_cutoff "
"from lineage_channel_weight"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Loader probes channel_set_code but not provenance columns

load_estimated_channel_weights probes table and channel_set_code existence to avoid a transaction-aborting statement, but still selects estimation_run_id and the other provenance columns unconditionally (backend/app/lineage_ingestion.py:118-124). On main these always exist since 0135, so it is safe there. Against a pre-0200 customer-master schema (no provenance columns) the query would fail and abort the caller's transaction.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

seonghobae added a commit that referenced this pull request Aug 24, 2026
Merge conflict: adjudication_client.py's judge() combined this branch's
extracted parse_confidence() helper with main's chat_completion_content()
response-body accessor -- use both together instead of the old direct
body["choices"][0]["message"]["content"] indexing.

Also carries the max_iter=3000 fix from #574 for the same shared
channel_weight_estimation.py module: fast-mlsirm's default max_iter=1000
is tuned against its GPU/f32 path, and the f64 CPU fallback every CI
runner uses needs materially more EM iterations to converge (observed
up to ~1850 locally under a forced rust_device="cpu" repro).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant