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
7 changes: 4 additions & 3 deletions backend/app/lineage_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ async def load_estimated_channel_weights(
the fallback constants.
"""
try:
rows = await conn.fetch(
"select channel_code, weight_value from lineage_channel_weight"
)
async with conn.transaction():
rows = await conn.fetch(
"select channel_code, weight_value from lineage_channel_weight"
)
except asyncpg.UndefinedTableError:
return None
Comment thread
seonghobae marked this conversation as resolved.
persisted = {row["channel_code"]: float(row["weight_value"]) for row in rows}
Expand Down
5 changes: 3 additions & 2 deletions docs/adr/0145-psychometric-channel-weight-estimation.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ exactly that information (McNeish & Wolf, 2020). Pairs are nested inside
reconstruction groups (process unit / corporate entity / thread), so a
single-level fit would commit the ecological/atomistic inference error
the standing mandate calls out (Robinson, 1950); `fast-mlsirm`'s MLS2PLM
is a *multilevel* 2PL whose `factor_id` models exactly this nesting, and
is a *multilevel* 2PL whose `cluster_id` models exactly this nesting, and
its `MLSIRMParams.alpha` field is the per-item log-discrimination
(natural-scale discrimination `exp(alpha)` is positive by construction,
so normalizing to sum 1 always yields valid convex weights).
Expand All @@ -49,7 +49,8 @@ unconfigured, never a fabricated result); the same pattern applies here.
weights by fitting `fast-mlsirm`'s MLS2PLM over observed channel
scores: items = channels, respondents = candidate pairs sampled the
same way `reconstruct` forms them (same grouping, same candidate
window), `factor_id` = the pair's reconstruction group (multilevel
window), `factor_id` assigns every channel item to the one relatedness
trait, and `cluster_id` = the pair's reconstruction group (multilevel
nesting per Robinson, 1950). Estimated weights are the normalized
natural-scale discriminations, `exp(alpha_j) / Σ exp(alpha_k)`
(Birnbaum, 1968).
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/channel_weight_estimation.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def estimate_channel_weights(
carry the same channel set -- a pair missing a channel is a
caller bug, not missing data to impute.
group_ids: the reconstruction-group index of each pair (same
length/order), used as MLS2PLM's multilevel ``factor_id``.
length/order), used as MLS2PLM's multilevel ``cluster_id``.

Returns:
The estimate, or ``None`` whenever a grounded estimate cannot be
Expand Down
40 changes: 40 additions & 0 deletions tests/test_lineage_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
import asyncio
from datetime import datetime, timezone

import asyncpg

from backend.app.lineage_ingestion import (
load_estimated_channel_weights,
reconstruct_group_key,
records_from_source_posts,
visible_lineage_graph,
Expand All @@ -14,6 +17,43 @@
from lineageweave.lineage_persistence import lineage_edge_specs


def test_missing_weight_table_rolls_back_before_fallback() -> None:
class _MissingTableConnection:
aborted = False

class Savepoint:
def __init__(self, connection: _MissingTableConnection) -> None:
self.connection = connection

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, traceback) -> bool:
self.connection.aborted = False
return False

def transaction(self):
return self.Savepoint(self)

async def fetch(self, query: str):
if "lineage_channel_weight" in query:
self.aborted = True
raise asyncpg.UndefinedTableError("synthetic missing table")
if self.aborted:
raise asyncpg.InFailedSQLTransactionError("transaction is aborted")
return []

connection = _MissingTableConnection()
weights = asyncio.run(
load_estimated_channel_weights(
connection, {"temporal", "secondary_key", "text"}
)
)
asyncio.run(connection.fetch("select 1"))

assert weights is None


def test_records_use_persisted_thread_keys_not_process_unit_or_voc_type() -> None:
rows = [
{
Expand Down