Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions backend/app/lineage_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,55 @@ async def load_estimated_channel_weights(
) -> dict[str, float] | None:
"""Load only a complete vector from an independently anchored method.

ADR 0145 currently authorizes no anchor method. A partial or invalid vector
returns ``None`` rather than being repaired. A database that has not applied
migration 0135 is likewise an unavailable state, detected without issuing a
statement that would abort the caller's outer PostgreSQL transaction.
No anchor method is currently authorized (ADR 0200 point 3 names the
conditions under which one becomes authorized). A partial or invalid
vector returns ``None`` rather than being repaired. A database that has
not applied migration 0135 is likewise an unavailable state, detected
without issuing a statement that would abort the caller's outer
PostgreSQL transaction.

Since migration 0200 one weight set is persisted per active-channel
combination (``channel_set_code``): the corpus-wide rebuild's three
deterministic channels and a scoped analysis run's four each match
their own set without regressing the other. Anything other than an
exact match of one set falls through to ``None`` -- a partial overlap
would mix estimation runs into a vector that grounds nothing.
"""
table_exists = await conn.fetchval(
"select to_regclass('public.lineage_channel_weight') is not null"
)
if not table_exists:
return None
rows = await conn.fetch(
"select channel_code, weight_value, estimation_run_id, "
# Pre-0200 schemas lack channel_set_code; probe via the catalog (never
# a failing statement, which would abort the caller's transaction).
# Pre-0200 rows form one implicit deterministic set.
set_column_exists = await conn.fetchval(
"select exists (select from information_schema.columns "
"where table_schema = 'public' "
" and table_name = 'lineage_channel_weight' "
" and column_name = 'channel_set_code')"
)
set_column_sql = (
"channel_set_code" if set_column_exists else "'channel_set_deterministic'"
)
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"
)
Comment on lines +118 to 124

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.

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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Expand Down
178 changes: 178 additions & 0 deletions docs/adr/0200-channel-weight-reconciliation.md
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# ADR 0200 — Reconciling channel-weight measurement across the two active lines

**Decision status:** Proposed
**Date:** 2026-08-24
**Amends:** [ADR 0003](0003-fast-mlsirm-report-integration.md) (scope
boundary), both lines' ADR 0145 (each in part — see Context)
**Answers:** the main-line ADR 0145's "a future proposal must be
ADR-first" conditions, issue #289's durable-worker requirement, and the
operator's no-bulk-synchronous-LLM directive of 2026-08-24.

> Numbering note: `0200` is deliberately above every number in use on
> `main` (≤ 0167), on `docs/customer-master-scope-adr` (≤ 0145), and in
> any open PR (≤ 0185), so it cannot collide when the lines converge.

## Context

Two long-lived lines implemented "ADR 0145" with **opposite decisions**:

- `docs/customer-master-scope-adr` estimates convex fusion weights with
fast-mlsirm's multilevel 2PL (channels = items, candidate pairs =
respondents, reconstruction groups = cluster intercepts), deletes the
hand-picked `DEFAULT_CHANNEL_WEIGHTS` outright, and fails product
reconstruction closed (HTTP 503 with an estimate-first next action)
whenever no persisted estimate matches the active channels. Weight
sets are keyed by `channel_set_code` (migration 0136).
- `main` records ADR 0145 as a **Rejected proposal** ("Channel-weight
estimation remains unavailable without an independent anchor"),
authorizes zero anchor methods (`_SUPPORTED_ANCHOR_METHOD_CODES =
frozenset()`, so its provenance-gated loader never activates a
vector), stubs the operator estimation command to an unconditional
refusal, and **retains the hand-picked constants "for
compatibility"**. Its `lineage_channel_weight` schema carries per-run
provenance columns instead of `channel_set_code`.

The same ADR number carrying contradictory decisions violates the
organization's exact-head consistency rule by itself. Beyond the
paperwork, the lines now diverge on the table schema, the loader
contract, the operator tooling, and — most importantly — on whether any
hand-picked constant may keep flowing through product reconstruction.

The operator's standing directive is explicit and repeated: **no
arbitrary weights anywhere; use weights estimated by a paper-grounded
psychometric model (fast-mlsirm or TEPP)**. The main line's retention of
uncalibrated constants "for compatibility" — however carefully labeled —
keeps exactly those arbitrary numbers in the product and cannot stand
under that directive.

At the same time, the main line's methodological critique of the first
estimation design is substantially correct and deserves engagement
rather than override:

1. **Criterion validity.** An unanchored IRT fit describes common
response structure among the channels; it does not by itself prove
that its latent factor is "these two posts are genuinely related."
2. **Conditional information.** Birnbaum's item information is
`I_j(θ) = a_j² P_j(θ) Q_j(θ)` — conditional on trait location — so a
weight proportional to the discrimination alone is not a globally
information-optimal fusion rule (Birnbaum, 1968; Lord, 1980).
3. **Scope boundary.** Accepted ADR 0003 assigned this repository's
fast-mlsirm integration to the LLM-judge/report path; the lineage
weight path must expand that boundary explicitly, not silently.

Operational facts sharpened this cycle: three estimation runs against
the shared orchestrator died to transport-lifetime failures before one
completed; the 400-pair sequential judge workload measurably saturated
the shared gateway (a concurrent `/api/ask` round-trip reached 158 s),
after which the operator directed that bulk LLM work must use the
repository's durable queue idiom rather than blocking synchronous HTTP;
and the shared development database was rebuilt from `main`, wiping the
imported corpus and every persisted estimate. Sequential-blocking
estimation is architecturally dead independent of the measurement
argument.

## Decision

1. **The directive governs both lines.** No hand-picked fusion weight
reaches any product path on any line. The scope line's fail-closed
contract (refuse with an estimate-first next action) becomes the
single product behavior; main's compatibility constants are retired.
Cormack et al.'s (2009) parameter-free reciprocal rank fusion remains
the Rankings surface's rule (no weights exist there to pick).
2. **Expected-information weights** replace discrimination-proportional
weights, answering critique (2): the fusion weight of channel *j* is
the normalized **expected item information over the fitted latent
distribution**,
`w_j ∝ E_θ[I_j(θ)] = ∫ a_j² P_j(θ) Q_j(θ) dF(θ)`,
with `F(θ)` the fitted multilevel latent distribution (mixture over
cluster intercepts). Integrating the conditionality instead of
ignoring it is the standard device of optimal test-design practice
(van der Linden, 2005). Method code:
`mls2plm_expected_information`. Every fit must pass fast-mlsirm's
official diagnostics; any non-converged fit is rejected outright
(`convergence_status`, per the pinned contract).
3. **Anchor honesty**, answering critique (1): estimation activates, but
every persisted set carries `anchor_method_code =
'unanchored_internal_structure'` until an independent anchor exists,
and provenance (method, estimator version, sample size, snapshot
digest, knowledge cutoff) is surfaced wherever the set is disclosed.
When TEPP reaches production, a criterion-validity gate correlates
fused scores with TEPP's event measurement on a frozen snapshot; a
set that fails the gate is retired and reconstruction fails closed
again. This amends ADR 0003 to authorize the lineage-weights path
explicitly under these conditions.
4. **Schema merge.** `lineage_channel_weight` takes the union of both
lines: primary key `(channel_set_code, channel_code)` from the scope
line — one persisted set per active-channel combination — plus the
main line's per-run provenance columns (`estimation_run_id`,
`estimation_method_code`, `estimator_version`, `anchor_method_code`,
`source_snapshot_sha256`, `sample_pair_count`, `knowledge_cutoff`).
The loader requires an exact active-channel match AND single-run
provenance integrity AND an authorized anchor method code
(`unanchored_internal_structure` joins the authorized set under
point 3's labeling duty). One migration with rollbacks lands the
union on whichever predecessor schema a database has.
5. **Queued judge scoring.** The llm channel's pair scoring moves to the
repository's durable queue idiom (`post_content_queue` /
`post_content_worker` family): the operator command samples pairs
deterministically, persists the run identity and per-pair job rows,
and publishes Valkey wake-ups; a bounded worker drains judge jobs at
a governed rate through contextual-orchestrator and persists each
pair's score durably as it lands. The fit runs only when a run's
pairs are complete, so a killed process loses nothing and re-running
resumes instead of re-spending provider calls. This satisfies the
operator's no-bulk-synchronous-LLM directive and issue #289's
bounded-durable-worker requirement in one design.
6. **Convergence sequencing.** This ADR lands verbatim on both lines;
each line's ADR 0145 gains a superseded-in-part pointer to it.
Implementation lands on `main` first (the shared development
environment now runs the main line), and `docs/customer-master-scope-adr`
rebases its weights stack onto the merged schema. Corpus re-import
and a fresh expected-information estimation run follow on the merged
head — in that order, since estimation samples the imported corpus.

## Consequences

**Positive.** One contract instead of two contradictory ones; the
operator directive holds everywhere; the estimator answers the
strongest published objection to its own first design; a killed
estimation run stops costing hours of provider spend; the shared
gateway is never again saturated by a scoring loop; and TEPP gains a
named, gated integration point instead of an implied one.

**Negative.** A schema migration on both predecessors; the llm channel
estimate waits for the queue worker to land; and until the TEPP gate
exists the activated weights remain honestly labeled as internally
anchored only — reviewers must weigh that label rather than a hard
criterion coefficient.

## References (APA 7th)

Birnbaum, A. (1968). Some latent trait models and their use in
inferring an examinee's ability. In F. M. Lord & M. R. Novick,
*Statistical theories of mental test scores* (pp. 397–479).
Addison-Wesley.

Cormack, G. V., Clarke, C. L. A., & Buettcher, S. (2009). Reciprocal
rank fusion outperforms Condorcet and individual rank learning methods.
*Proceedings of the 32nd International ACM SIGIR Conference on Research
and Development in Information Retrieval*, 758–759.
https://doi.org/10.1145/1571941.1572114

Fox, J.-P., & Glas, C. A. W. (2001). Bayesian estimation of a multilevel
IRT model using Gibbs sampling. *Psychometrika, 66*(2), 271–288.
https://doi.org/10.1007/BF02294839

Lord, F. M. (1980). *Applications of item response theory to practical
testing problems*. Lawrence Erlbaum Associates.

McNeish, D., & Wolf, M. G. (2020). Thinking twice about sum scores.
*Behavior Research Methods, 52*(6), 2287–2305.
https://doi.org/10.3758/s13428-020-01398-0

Robinson, W. S. (1950). Ecological correlations and the behavior of
individuals. *American Sociological Review, 15*(3), 351–357.
https://doi.org/10.2307/2087176

van der Linden, W. J. (2005). *Linear models for optimal test design*.
Springer. https://doi.org/10.1007/0-387-29054-0
46 changes: 34 additions & 12 deletions lineageweave/adjudication_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,36 @@ def judge(self, candidate_label: str, record_label: str) -> float: # pragma: no
_CONFIDENCE_PATTERN = re.compile(r"([01](?:\.\d+)?)")


def judge_prompt(candidate_label: str, record_label: str) -> str:
"""The one adjudication prompt, shared by the live client and the
queued batch scorer so both channels ask the identical question."""
return (
"On a scale from 0.0 (definitely unrelated) to 1.0 (definitely the same "
"thread, B directly follows from A), how confident are you that record B "
"is a direct continuation of record A? Reply with only the number.\n\n"
f"Record A: {candidate_label}\nRecord B: {record_label}"
)


def parse_confidence(content: str) -> float:
"""Clamp the judge's numeric reply into [0, 1]; no number reads as 0."""
parsed = parse_confidence_or_none(content)
return 0.0 if parsed is None else parsed


def parse_confidence_or_none(content: str) -> float | None:
"""Like :func:`parse_confidence`, but an unparseable reply is ``None``.

The queued batch scorer must distinguish "the judge said 0.0" from
"the judge failed to answer" -- persisting the latter as a confident
zero would fabricate an unrelated verdict for an errored request.
"""
match = _CONFIDENCE_PATTERN.search(content)
if match is None:
return None
return max(0.0, min(1.0, float(match.group(1))))


class ContextualOrchestratorAdjudicationClient:
"""Calls ``POST {base_url}/v1/chat/completions`` with ``mode="auto"``.

Expand All @@ -60,24 +90,16 @@ def __init__(

def judge(self, candidate_label: str, record_label: str) -> float:
"""Score the candidate and record labels for semantic adjudication."""
prompt = (
"On a scale from 0.0 (definitely unrelated) to 1.0 (definitely the same "
"thread, B directly follows from A), how confident are you that record B "
"is a direct continuation of record A? Reply with only the number.\n\n"
f"Record A: {candidate_label}\nRecord B: {record_label}"
)
body = post_json(
f"{self._base_url}/v1/chat/completions",
{
"messages": [{"role": "user", "content": prompt}],
"messages": [
{"role": "user", "content": judge_prompt(candidate_label, record_label)}
],
"mode": "auto",
"reasoning_effort": self._reasoning_effort,
},
headers={"authorization": f"Bearer {self._api_key}"},
timeout=self._timeout,
)
content = chat_completion_content(body)
match = _CONFIDENCE_PATTERN.search(content)
if match is None:
return 0.0
return max(0.0, min(1.0, float(match.group(1))))
return parse_confidence(chat_completion_content(body))
Loading
Loading