Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,11 @@ in the same spirit) -- never against real data, per the hard rule above.
against a live local stack (`make up`) and self-skip without one -- see
[README.md](README.md#local-product-stack-docker-compose).

Period leftover pairs (ADR 0017 / 0018) are computed in
Period leftover pairs (ADR 0048 / 0049 / 0119) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Missing cells stay out of the
Gabriel factorization. Closest and farthest post–criterion pairs
GRM/GPCM score, never invented. Distances are Euclidean on the
two-dimensional Gabriel leftover map; missing cells stay out of
the factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` and sit above the member list so
a click opens that post.

Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring,
`information_polytomous` ranks the shared-bank items by Fisher
information at the group's mean θ (Lord, 1980 max-info CAT). Rankings
persist to `report_item_information`. After those IRT main effects,
residual SVD leftover pairs (Jeon et al., 2021; ADR 0017) persist to
residual SVD leftover pairs on two Gabriel axes (Jeon et al., 2021; ADR 0048 / 0119) persist to
`report_leftover_pair`. Results persist to
`report_period_score` / `report_member_score`.
`GET /api/reports/{grouping}` lists the trend;
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.d/2.12.13-leftover-map-two-dimensional-distance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## 2.12.13 — Two-dimensional leftover-map distances

- Measure closest and farthest leftover post–criterion distances on
the two-axis Jeon / Gabriel interaction map (ADR 0119). Hidden SVD
axes after the second no longer change `leftover_distance`. Rank-0
and rank-1 maps still pad unused axes with zero. After `make seed`,
leftover pairs above the member list still open that post. Never
invent a leftover score or a theta.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ All notable changes to this project are documented here. Format follows
environment, so local OIDC and synthetic-data workflows resolve the same
pinned dependencies as CI.

## [2.12.13] - 2026-08-24

### Fixed

- Closest and farthest leftover post–criterion distances are now
Euclidean on the two-axis Jeon / Gabriel interaction map (ADR 0119).
Hidden SVD axes after the second no longer change `leftover_distance`.
Rank-0 and rank-1 maps still pad unused axes with zero, so seed
leftover pairs above the member list still open that post. Never
invent a leftover score or a theta.

## [2.12.6] - 2026-08-20

### Added
Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0048-persist-lsirm-leftover-pairs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

**Decision status:** Accepted
**Date:** 2026-08-17
**Amended by:** [ADR 0119](0119-leftover-map-two-dimensional-distance.md) (two leftover-map axes)

## Context

Expand Down Expand Up @@ -30,7 +31,8 @@ and one `farthest` observed cell per period report in
`report_leftover_pair` (3NF, two-or-more-word `snake_case`).

The biplot lives in `lineageweave/leftover_pairs.py` so leftover
tests do not import `period_report` or `fast_mlsirm`.
tests do not import `period_report` or `fast_mlsirm`. Distances are
Euclidean on the two leftover-map axes (ADR 0119).

Cascade the rows with `report_period_score`. A leftover post must
also be a `report_member_score` row, and the leftover criterion
Expand Down
53 changes: 53 additions & 0 deletions docs/adr/0119-leftover-map-two-dimensional-distance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ADR 0119 — Measure leftover-map distances on two Gabriel axes

**Decision status:** Accepted
**Date:** 2026-08-24

Amends [ADR 0048](0048-persist-lsirm-leftover-pairs.md).

## Context

ADR 0048 persists closest and farthest leftover post–criterion pairs
after IRT main effects. Jeon et al. (2021, eq. 3) place the leftover
interaction `−γ‖ξ_p − ζ_i‖` on a two-dimensional person–item map.
Gabriel (1971) supplies those coordinates from a residual biplot.

`leftover_pairs.py` previously measured Euclidean distance on every
kept SVD axis. A rank-3 residual therefore reported a leftover
distance that a buyer cannot read on the 2D interaction map, and
that would disagree with stored two-axis coordinates if those later
persist. Rank-1 seed fixtures still passed because unused axes were
absent, not because the estimator was two-dimensional.

## Decision

Closest and farthest leftover distances are Euclidean on **exactly
two** leftover-map axes. `_leftover_map_positions` may still return
the full Gabriel factorization; `_pad_map_axes` pads a rank-0 or
rank-1 map with zeros and truncates hidden axes after the second.
Missing cells stay out of the factorization. Rank-0 residuals still
emit a stable pair with distance zero. Do not invent a leftover
score. Do not invent a theta.

This slice does not persist map coordinates or change leftover UI.
Those remain ADR 0048 / 0049, and persist-map tables stay on their
own PR stack.

## Consequences

`leftover_distance` on `report_leftover_pair` matches the 2D Jeon
map. After `make seed`, closest and farthest pairs above the member
list still open that post. A rank-3 synthetic residual proves the
stored distance equals the two-axis hypot and is not the full-rank
norm.

## References

Gabriel, K. R. (1971). The biplot graphic display of matrices with
application to principal component analysis. *Biometrika, 58*(3),
453–467. https://doi.org/10.1093/biomet/58.3.453

Jeon, M., Jin, I. H., Schweinberger, M., & Baugh, S. (2021). Mapping
unobserved item–respondent interactions: A latent space item response
model with interaction map. *Psychometrika, 86*(2), 378–403.
https://doi.org/10.1007/s11336-021-09762-5
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.12.6",
"version": "2.12.13",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
27 changes: 21 additions & 6 deletions lineageweave/leftover_pairs.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Jeon leftover post–criterion pairs after a main-effect IRT (ADR 0017).
"""Jeon leftover post–criterion pairs after a main-effect IRT (ADR 0048 / 0119).

Does not import ``fast_mlsirm`` or ``period_report``. A Gabriel biplot
of the residual ``R = Y − E[Y|θ, item]`` supplies person and item
positions. Missing response cells are excluded from the factorization;
they are never treated as zero residuals.
they are never treated as zero residuals. Pair distances are Euclidean
on the two leftover-map axes (Jeon et al., 2021); unused axes pad with
zero rather than inventing a second component, and hidden SVD axes
after the second are dropped.
"""

from __future__ import annotations
Expand All @@ -15,6 +18,7 @@
PAIR_KIND_CLOSEST = "closest"
PAIR_KIND_FARTHEST = "farthest"
_LEFTOVER_SINGULAR_FLOOR = 1e-12
_LEFTOVER_MAP_AXES = 2


@dataclass(frozen=True)
Expand All @@ -38,9 +42,10 @@ def leftover_pairs_from_residual(

Jeon et al. (2021) leftover interaction is ``−γ‖ξ_p − ζ_i‖``. This
estimator places persons and items from the residual after IRT main
effects (Gabriel, 1971). Only observed cells become pairs. A rank-0
residual still emits a stable closest/farthest pair so seed is not
empty; it does not invent a leftover score.
effects (Gabriel, 1971). Only observed cells become pairs. Distances
use the two leftover-map axes; a rank-0 residual still emits a
stable closest/farthest pair so seed is not empty and does not
invent a leftover score.
"""
if matrix.shape != (len(post_ids), len(item_codes)):
raise ValueError(
Expand Down Expand Up @@ -72,13 +77,15 @@ def leftover_pairs_from_residual(
if person_pos is not None and item_pos is not None:
person_index = np.flatnonzero(keep_person)
item_index = np.flatnonzero(keep_item)
person_xy = _pad_map_axes(person_pos)
item_xy = _pad_map_axes(item_pos)
local_person = {int(person): local for local, person in enumerate(person_index)}
local_item = {int(item): local for local, item in enumerate(item_index)}
for person, item in observed:
if person not in local_person or item not in local_item:
continue
distance = float(
np.linalg.norm(person_pos[local_person[person]] - item_pos[local_item[item]])
np.linalg.norm(person_xy[local_person[person]] - item_xy[local_item[item]])
)
if not np.isfinite(distance):
continue
Expand Down Expand Up @@ -166,3 +173,11 @@ def _leftover_map_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]
person_pos = left[:, keep] * scale
item_pos = right[keep, :].T * scale
return person_pos, item_pos
Comment thread
seonghobae marked this conversation as resolved.


def _pad_map_axes(positions: np.ndarray) -> np.ndarray:
"""Pad or truncate Gabriel coordinates to two leftover-map axes."""
padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64)
width = min(_LEFTOVER_MAP_AXES, positions.shape[1])
padded[:, :width] = positions[:, :width]
return padded
Comment thread
seonghobae marked this conversation as resolved.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lineageweave"
version = "2.12.6"
version = "2.12.13"
description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication."
readme = "README.md"
license = { text = "MIT" }
Expand Down
59 changes: 58 additions & 1 deletion tests/test_leftover_pairs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Leftover post–criterion pairs after the main-effect IRT (ADR 0017).
"""Leftover post–criterion pairs after the main-effect IRT (ADR 0048 / 0119).

Uses a constructed residual matrix so the closest and farthest pair
are known without calling ``fit_polytomous``. Loads
Expand All @@ -17,6 +17,8 @@
import pytest

_LEFTOVER_PATH = Path(__file__).resolve().parents[1] / "lineageweave" / "leftover_pairs.py"
_LEFTOVER_SINGULAR_FLOOR = 1e-12
_LEFTOVER_MAP_AXES = 2


def _load_leftover():
Expand All @@ -43,6 +45,21 @@ def _load_leftover():
leftover_pairs_from_residual = leftover.leftover_pairs_from_residual


def _gabriel_positions(filled: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Independent Gabriel coordinates used to prove leftover_distance axes."""
left, singular, right = np.linalg.svd(filled, full_matrices=False)
keep = singular > _LEFTOVER_SINGULAR_FLOOR
scale = np.sqrt(singular[keep])
return left[:, keep] * scale, right[keep, :].T * scale


def _pad_map_axes(positions: np.ndarray) -> np.ndarray:
padded = np.zeros((positions.shape[0], _LEFTOVER_MAP_AXES), dtype=np.float64)
width = min(_LEFTOVER_MAP_AXES, positions.shape[1])
padded[:, :width] = positions[:, :width]
return padded


def test_leftover_residual_biplot_separates_aligned_and_opposed_cells() -> None:
"""A rank-1 leftover spike puts the aligned cell closest and the opposed cell farthest."""
post_ids = ["post-a", "post-b", "post-c"]
Expand Down Expand Up @@ -116,3 +133,43 @@ def test_leftover_is_empty_without_observed_cells() -> None:
matrix = np.array([[np.nan]], dtype=np.float64)
expected = np.array([[0.0]], dtype=np.float64)
assert leftover_pairs_from_residual(post_ids, item_codes, matrix, expected) == ()


def test_rank_three_pair_distances_match_two_dimensional_gabriel_coords() -> None:
"""Jeon leftover_distance is Euclidean on the 2D map, not the full SVD rank."""
post_ids = ["post-a", "post-b", "post-c", "post-d"]
item_codes = ("item-a", "item-b", "item-c", "item-d")
matrix = np.array(
[
[4.0, 1.0, 0.0, -1.0],
[0.0, 3.0, 1.0, -2.0],
[-2.0, 0.0, 2.0, 1.0],
[1.0, -1.0, 0.0, 4.0],
],
dtype=np.float64,
)
expected = np.zeros_like(matrix)
filled = matrix - float(np.mean(matrix))
person_full, item_full = _gabriel_positions(filled)
assert person_full.shape[1] >= 3
person_map = _pad_map_axes(person_full)
item_map = _pad_map_axes(item_full)
full_distances = np.linalg.norm(person_full[:, None, :] - item_full[None, :, :], axis=2)
map_distances = np.linalg.norm(person_map[:, None, :] - item_map[None, :, :], axis=2)
assert float(np.max(np.abs(full_distances - map_distances))) > 1e-6

pairs = leftover_pairs_from_residual(post_ids, item_codes, matrix, expected)
assert [pair.pair_kind for pair in pairs] == [PAIR_KIND_CLOSEST, PAIR_KIND_FARTHEST]
post_index = {post_id: index for index, post_id in enumerate(post_ids)}
item_index = {code: index for index, code in enumerate(item_codes)}
for pair in pairs:
person = post_index[pair.post_id]
item = item_index[pair.criterion_code]
assert pair.leftover_distance == pytest.approx(float(map_distances[person, item]))
assert pair.leftover_distance != pytest.approx(
float(full_distances[person, item]), abs=1e-9
)

farthest_map = np.unravel_index(int(np.argmax(map_distances)), map_distances.shape)
farthest = pairs[1]
assert (post_index[farthest.post_id], item_index[farthest.criterion_code]) == farthest_map
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading