PHON-106 — CSP Contrastive Scorers Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Pivot CSP contrastive enforcement from post-hoc cross-slot scoring to linked-slot enumeration. Define MinpairConstraint / MaxoppConstraint / MultoppConstraint constraint types. Emit data/runtime/pairs.parquet (built from existing _compute_minimal_pairs data plus continuous feature_distance / sonorant_diff from learned posterior vectors). Wire minpair + maxopp into the vectorized solver via linked-slot mode on single-sentence shapes; multopp is defined but defers to PHON-108.
Architecture: Two tiers. Pipeline tier: extend _compute_minimal_pairs with feature distances, emit pairs.parquet, load in WordStore, switch export-to-d1.py to use WordStore as single source of truth. CSP tier: replace the single ContrastiveConstraint with three types; in solve_shape, when a Minpair/Maxopp constraint is present, switch to linked-slot mode — pair_rows × non-linked-slot Cartesian instead of independent slot Cartesian. Multopp errors out in single-sentence shapes.
Tech Stack: Python 3.12, Polars 1.0+, pytest, NumPy (for cosine distance).
Spec: docs/superpowers/specs/2026-05-09-phon-106-csp-contrastive-scorers-design.md
File map¶
| File | Action |
|---|---|
packages/data/src/phonolex_data/pipeline/derived.py |
Modify — _compute_minimal_pairs returns 8-tuple; add _phoneme_pair_distances helper |
packages/data/src/phonolex_data/pipeline/schema.py |
Modify — Derived.minimal_pairs tuple shape extends to 8 elements |
packages/data/src/phonolex_data/runtime/schema.py |
Modify — add pairs_schema() |
packages/data/src/phonolex_data/runtime/emit_parquet.py |
Modify — emit data/runtime/pairs.parquet |
packages/data/src/phonolex_data/runtime/store.py |
Modify — WordStore.from_parquet() also loads pairs_df |
packages/web/workers/scripts/export-to-d1.py |
Modify — read pairs via WordStore (single source of truth) |
.gitattributes |
Modify — LFS-track data/runtime/pairs.parquet |
packages/generation/research/2026-05-07-sentence-generation-paradigms/constraint_surface.py |
Modify — replace ContrastiveConstraint with MinpairConstraint/MaxoppConstraint/MultoppConstraint; rewrite cross_slot_axes accordingly |
packages/generation/research/2026-05-07-sentence-generation-paradigms/skeleton_csp.py |
Modify — _should_use_vectorized narrows; add linked-slot mode to _enumerate_vectorized; validation in solve_shape |
packages/generation/research/2026-05-07-sentence-generation-paradigms/paradigm_3_csp.py |
Modify — update ContrastiveConstraint imports |
packages/generation/research/2026-05-07-sentence-generation-paradigms/paragraph_csp.py |
Modify — same import update |
packages/generation/research/2026-05-07-sentence-generation-paradigms/test_vectorized_enumeration.py |
Modify — update imports; existing minpair test stays |
packages/generation/research/2026-05-07-sentence-generation-paradigms/test_contrastive_scorers.py |
Create — new test file |
data/runtime/pairs.parquet |
Create (regenerated artifact) |
All paths in this plan are relative to repo root /Users/jneumann/Repos/PhonoLex/. The spike directory is referenced as <spike>/ for brevity:
<spike>/ = packages/generation/research/2026-05-07-sentence-generation-paradigms/.
Test commands throughout:
# Spike tests (CSP layer)
cd packages/generation && uv run python -m pytest research/2026-05-07-sentence-generation-paradigms/ -v
# Data tests (pipeline layer)
cd packages/data && uv run python -m pytest tests/ -v
Task 1: Add _phoneme_pair_distances helper in derived.py¶
Files:
- Modify: packages/data/src/phonolex_data/pipeline/derived.py
- Modify: packages/data/tests/test_derived.py (or create if absent)
Pure function: given the learned vectors dict from _compute_phoneme_data, return a dict[(p1, p2), (feature_distance, sonorant_diff)] lookup for fast per-pair access in the upcoming _compute_minimal_pairs extension.
- [ ] Step 1.1: Write failing test
Find or create packages/data/tests/test_derived.py. Append:
def test_phoneme_pair_distances_basic():
import numpy as np
from phonolex_data.pipeline.derived import _phoneme_pair_distances
# Synthetic vectors: 2 phonemes, 3 features, sonorant at index 0
vectors = {
"p": np.array([0.0, 0.5, 0.5]), # non-sonorant
"m": np.array([1.0, 0.5, 0.5]), # sonorant
}
feature_names = ["sonorant", "voice", "labial"]
distances = _phoneme_pair_distances(vectors, feature_names)
# Symmetric: both (p, m) and (m, p) keys present
assert ("p", "m") in distances
assert ("m", "p") in distances
feat_dist, son_diff = distances[("p", "m")]
# L2 distance: sqrt((1-0)^2 + 0 + 0) = 1.0
assert abs(feat_dist - 1.0) < 1e-6
# Sonorant diff: |1.0 - 0.0| = 1.0
assert abs(son_diff - 1.0) < 1e-6
# (p, p) not in dict — only distinct pairs
assert ("p", "p") not in distances
- [ ] Step 1.2: Run test, verify it fails
cd packages/data && uv run python -m pytest tests/test_derived.py::test_phoneme_pair_distances_basic -v
Expected: ImportError or AttributeError: module 'phonolex_data.pipeline.derived' has no attribute '_phoneme_pair_distances'.
- [ ] Step 1.3: Implement
_phoneme_pair_distances
Append to packages/data/src/phonolex_data/pipeline/derived.py (just below _compute_phoneme_data):
def _phoneme_pair_distances(
vectors: dict[str, np.ndarray],
feature_names: list[str],
) -> dict[tuple[str, str], tuple[float, float]]:
"""Build per-(phoneme1, phoneme2) lookup of (feature_distance, sonorant_diff).
feature_distance is L2 distance between the two posterior vectors.
sonorant_diff is |vec1[sonorant] - vec2[sonorant]|, in [0, 1].
Both directions emitted (p1, p2) and (p2, p1) — symmetric, but caller
code is simpler with two-direction dict than with sorted-key lookup.
"""
try:
sonorant_idx = feature_names.index("sonorant")
except ValueError as e:
raise ValueError(
"feature_names must include 'sonorant' for major-class-diff scoring"
) from e
out: dict[tuple[str, str], tuple[float, float]] = {}
phonemes = list(vectors.keys())
for i, p1 in enumerate(phonemes):
v1 = vectors[p1]
for p2 in phonemes[i + 1:]:
v2 = vectors[p2]
feat_dist = float(np.linalg.norm(v1 - v2))
son_diff = float(abs(v1[sonorant_idx] - v2[sonorant_idx]))
out[(p1, p2)] = (feat_dist, son_diff)
out[(p2, p1)] = (feat_dist, son_diff)
return out
- [ ] Step 1.4: Run test, verify it passes
cd packages/data && uv run python -m pytest tests/test_derived.py::test_phoneme_pair_distances_basic -v
Expected: 1 passed.
- [ ] Step 1.5: Commit
git add packages/data/src/phonolex_data/pipeline/derived.py \
packages/data/tests/test_derived.py
git commit -m "$(cat <<'EOF'
PHON-106: add _phoneme_pair_distances helper
Pure function: given vectors dict + feature_names list, return a per-
(phoneme1, phoneme2) lookup of (feature_distance, sonorant_diff) for
use by _compute_minimal_pairs. Both directions emitted for caller
simplicity.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Task 2: Extend _compute_minimal_pairs to emit 8-tuple¶
Files:
- Modify: packages/data/src/phonolex_data/pipeline/derived.py
- Modify: packages/data/src/phonolex_data/pipeline/schema.py
- Modify: packages/data/tests/test_derived.py
The existing function returns 6-element tuples (w1, w2, ph1, ph2, pos, pos_type). Extend to 8 elements: (w1, w2, ph1, ph2, pos, pos_type, feature_distance, sonorant_diff). Re-key the bucketing logic to also emit the distance fields per pair.
- [ ] Step 2.1: Write failing test
Append to packages/data/tests/test_derived.py:
def test_compute_minimal_pairs_includes_distance_columns():
"""Each minimal-pair row carries feature_distance and sonorant_diff."""
import numpy as np
from phonolex_data.pipeline.derived import _compute_minimal_pairs
from phonolex_data.pipeline.schema import WordRecord
# Two minimal-pair words: cat /k æ t/ and bat /b æ t/ — differ at position 0
words = {
"cat": WordRecord(
word="cat", phonemes=["k", "æ", "t"],
has_phonology=True, syllables=[],
),
"bat": WordRecord(
word="bat", phonemes=["b", "æ", "t"],
has_phonology=True, syllables=[],
),
}
# Synthetic vectors: k and b differ only on voice
vectors = {
"k": np.array([0.0, 0.0, 0.0]),
"b": np.array([0.0, 1.0, 0.0]),
"æ": np.array([1.0, 1.0, 0.0]),
"t": np.array([0.0, 0.0, 0.0]),
}
feature_names = ["sonorant", "voice", "labial"]
pairs = _compute_minimal_pairs(words, vectors=vectors, feature_names=feature_names)
# One pair (cat, bat) at position 0
assert len(pairs) == 1
pair = pairs[0]
assert len(pair) == 8, f"expected 8-tuple, got {len(pair)}: {pair}"
w1, w2, ph1, ph2, pos, pos_type, feat_dist, son_diff = pair
assert {w1, w2} == {"cat", "bat"}
assert {ph1, ph2} == {"k", "b"}
assert pos == 0
assert pos_type == "initial"
# k and b differ on voice only → L2 distance = 1.0
assert abs(feat_dist - 1.0) < 1e-6
# Both non-sonorant → sonorant_diff = 0.0
assert abs(son_diff - 0.0) < 1e-6
- [ ] Step 2.2: Run test, verify it fails
cd packages/data && uv run python -m pytest tests/test_derived.py::test_compute_minimal_pairs_includes_distance_columns -v
Expected: TypeError on tuple unpacking (still 6-tuple), or signature mismatch on vectors kwarg.
- [ ] Step 2.3: Update
_compute_minimal_pairssignature + body
Find the function in packages/data/src/phonolex_data/pipeline/derived.py (around line 179). The existing signature:
def _compute_minimal_pairs(
words: dict[str, WordRecord],
) -> list[tuple[str, str, str, str, int, str]]:
Change to:
def _compute_minimal_pairs(
words: dict[str, WordRecord],
*,
vectors: dict[str, np.ndarray],
feature_names: list[str],
) -> list[tuple[str, str, str, str, int, str, float, float]]:
"""Precompute minimal pairs with feature_distance and sonorant_diff
per pair, derived from the learned posterior vectors.
Returns 8-tuples: (word1, word2, phoneme1, phoneme2, position,
position_type, feature_distance, sonorant_diff).
"""
Then inside the body, build the distance lookup once at function entry:
pair_distances = _phoneme_pair_distances(vectors, feature_names)
In the section that emits pairs (current line ~228 area), look up (ph1, ph2) in pair_distances. If the pair is missing (e.g., one phoneme isn't in vectors), default to (0.0, 0.0) and emit anyway:
# Inside the existing append logic, replace:
# minimal_pairs.append((w1, w2, ph1, ph2, pos, pos_type))
# with:
feat_dist, son_diff = pair_distances.get((ph1, ph2), (0.0, 0.0))
minimal_pairs.append((w1, w2, ph1, ph2, pos, pos_type, feat_dist, son_diff))
- [ ] Step 2.4: Update the caller in
derived.py
Find where _compute_minimal_pairs(words) is called (around line 265 in the compute_derived function). It currently calls without the vectors/feature_names kwargs. Update the call to pass them — they're already available via learned (the input):
# Existing context near line 265:
phonemes_data, phoneme_norms, phoneme_dots = _compute_phoneme_data(learned)
# ... other derived computations ...
# Replace the existing line:
# minimal_pairs = _compute_minimal_pairs(words)
# with:
minimal_pairs = _compute_minimal_pairs(
words,
vectors=learned["vectors"],
feature_names=learned["feature_names"],
)
- [ ] Step 2.5: Update
Derivedschema
Find packages/data/src/phonolex_data/pipeline/schema.py. Locate the Derived dataclass field minimal_pairs. The current field:
minimal_pairs: list[tuple] = field(default_factory=list)
Update the type annotation (though tuple is already broad, make the intent explicit):
# 8-tuple per pair: (word1, word2, phoneme1, phoneme2, position,
# position_type, feature_distance, sonorant_diff)
minimal_pairs: list[tuple[str, str, str, str, int, str, float, float]] = field(
default_factory=list
)
- [ ] Step 2.6: Run test, verify it passes
cd packages/data && uv run python -m pytest tests/test_derived.py -v
Expected: all derived.py tests pass (existing + 2 new from Tasks 1 and 2).
- [ ] Step 2.7: Commit
git add packages/data/src/phonolex_data/pipeline/derived.py \
packages/data/src/phonolex_data/pipeline/schema.py \
packages/data/tests/test_derived.py
git commit -m "$(cat <<'EOF'
PHON-106: _compute_minimal_pairs emits 8-tuple with feature distance
Each minimal-pair row now carries feature_distance and sonorant_diff
from the learned posterior vectors, enabling continuous maxopp scoring
in the CSP. Falls back to (0.0, 0.0) for any phoneme not present in
the vectors dict (defensive — shouldn't fire given the CMU inventory
filter in _compute_phoneme_data).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Task 3: Add pairs_schema() and emit pairs.parquet¶
Files:
- Modify: packages/data/src/phonolex_data/runtime/schema.py
- Modify: packages/data/src/phonolex_data/runtime/emit_parquet.py
- Modify: packages/data/tests/ (test_emit_parquet.py or test_schema.py — wherever schemas are tested)
- [ ] Step 3.1: Add
pairs_schema()toruntime/schema.py
Append to packages/data/src/phonolex_data/runtime/schema.py:
def pairs_schema() -> pl.Schema:
"""Polars schema for data/runtime/pairs.parquet — minimal pair edges
with continuous feature_distance and sonorant_diff for maxopp scoring."""
return pl.Schema({
"word1": pl.String,
"word2": pl.String,
"phoneme1": pl.String,
"phoneme2": pl.String,
"position": pl.UInt8,
"position_type": pl.String,
"feature_distance": pl.Float32,
"sonorant_diff": pl.Float32,
})
If there are existing tests for words_schema() etc., add a parallel test:
def test_pairs_schema_columns():
from phonolex_data.runtime.schema import pairs_schema
schema = pairs_schema()
assert set(schema.names()) == {
"word1", "word2", "phoneme1", "phoneme2",
"position", "position_type",
"feature_distance", "sonorant_diff",
}
- [ ] Step 3.2: Update
emit_parquet.pyto emitpairs.parquet
Find packages/data/src/phonolex_data/runtime/emit_parquet.py. It contains an emit_parquet function (or similar) that writes words.parquet, edges.parquet, selectional.parquet from Derived. Add a pairs.parquet emission alongside.
Locate where selectional.parquet is written. After it, add:
# pairs.parquet — minimal-pair edges with continuous feature distances (PHON-106)
pairs_rows = derived.minimal_pairs
pairs_df = pl.DataFrame(
{
"word1": [r[0] for r in pairs_rows],
"word2": [r[1] for r in pairs_rows],
"phoneme1": [r[2] for r in pairs_rows],
"phoneme2": [r[3] for r in pairs_rows],
"position": [r[4] for r in pairs_rows],
"position_type": [r[5] for r in pairs_rows],
"feature_distance": [r[6] for r in pairs_rows],
"sonorant_diff": [r[7] for r in pairs_rows],
},
schema=pairs_schema(),
)
pairs_path = output_dir / "pairs.parquet"
pairs_df.write_parquet(pairs_path)
print(f" wrote {pairs_path} ({pairs_df.height:,} rows)")
Add from phonolex_data.runtime.schema import pairs_schema (or extend the existing import line) at the top of the file.
- [ ] Step 3.3: Smoke-test the emit
cd packages/data && uv run python scripts/build_runtime_parquet.py
Expected: command exits 0, prints wrote .../pairs.parquet with a row count > 0.
Then verify the schema:
uv run python -c "
import polars as pl
df = pl.read_parquet('data/runtime/pairs.parquet')
print('schema:', df.schema)
print('shape:', df.shape)
print(df.head(5))
"
Expected: 8 columns matching pairs_schema(), ~300K–500K rows, sample rows show real (word1, word2) pairs.
- [ ] Step 3.4: LFS-track the new artifact
Find .gitattributes at the repo root. The existing entry for data/runtime/*.parquet should already cover pairs.parquet:
grep "data/runtime" .gitattributes
If the existing entry uses a wildcard like data/runtime/*.parquet filter=lfs ..., no change needed. If it lists files explicitly, add a line:
data/runtime/pairs.parquet filter=lfs diff=lfs merge=lfs -text
- [ ] Step 3.5: Commit (separate from the parquet artifact itself)
git add packages/data/src/phonolex_data/runtime/schema.py \
packages/data/src/phonolex_data/runtime/emit_parquet.py \
packages/data/tests/test_schema.py \
.gitattributes
git commit -m "$(cat <<'EOF'
PHON-106: add pairs_schema() and emit pairs.parquet
Schema parallels words/edges/selectional. Pipeline now writes
data/runtime/pairs.parquet alongside the other runtime artifacts.
.gitattributes covers it via the existing data/runtime/*.parquet
LFS rule (or adds an explicit rule if the wildcard isn't already
present).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
- [ ] Step 3.6: Commit the regenerated parquet artifact
git add data/runtime/pairs.parquet
git commit -m "$(cat <<'EOF'
PHON-106: regenerate data/runtime/pairs.parquet (LFS)
Output of the updated pipeline: ~Nk rows × 8 columns, including
continuous feature_distance and sonorant_diff per pair.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
(Replace ~Nk in the commit message with the actual row count from Step 3.3.)
Task 4: Load pairs_df in WordStore.from_parquet¶
Files:
- Modify: packages/data/src/phonolex_data/runtime/store.py
- Modify: packages/data/tests/test_store.py (or wherever store is tested)
- [ ] Step 4.1: Write failing test
Find or create packages/data/tests/test_store.py. Append:
def test_word_store_loads_pairs_df(tmp_path):
"""WordStore.from_parquet loads pairs.parquet as store.pairs_df."""
from pathlib import Path
from phonolex_data.runtime.store import WordStore
repo_root = Path(__file__).resolve().parents[3]
words_path = repo_root / "data" / "runtime" / "words.parquet"
if not words_path.exists():
pytest.skip("data/runtime/words.parquet not present (LFS not pulled)")
store = WordStore.from_parquet(words_path)
assert hasattr(store, "pairs_df"), "WordStore should expose pairs_df"
assert store.pairs_df is not None
assert store.pairs_df.height > 0
assert "feature_distance" in store.pairs_df.columns
- [ ] Step 4.2: Run test, verify it fails
cd packages/data && uv run python -m pytest tests/test_store.py::test_word_store_loads_pairs_df -v
Expected: AttributeError — store has no pairs_df.
- [ ] Step 4.3: Update
WordStore
Find packages/data/src/phonolex_data/runtime/store.py. Locate the WordStore dataclass and from_parquet classmethod. Add a pairs_df field and load it:
import polars as pl
@dataclass
class WordStore:
df: pl.DataFrame
edges_df: pl.DataFrame | None = None
selectional_df: pl.DataFrame | None = None
pairs_df: pl.DataFrame | None = None # NEW (PHON-106)
@classmethod
def from_parquet(cls, words_path: Path) -> "WordStore":
runtime_dir = words_path.parent
df = pl.read_parquet(words_path)
edges_path = runtime_dir / "edges.parquet"
edges_df = pl.read_parquet(edges_path) if edges_path.exists() else None
selectional_path = runtime_dir / "selectional.parquet"
selectional_df = pl.read_parquet(selectional_path) if selectional_path.exists() else None
pairs_path = runtime_dir / "pairs.parquet"
pairs_df = pl.read_parquet(pairs_path) if pairs_path.exists() else None
return cls(df=df, edges_df=edges_df, selectional_df=selectional_df, pairs_df=pairs_df)
If WordStore doesn't currently follow this exact dataclass+classmethod pattern, adapt: the goal is store.pairs_df is populated after from_parquet if pairs.parquet exists.
- [ ] Step 4.4: Run test, verify it passes
cd packages/data && uv run python -m pytest tests/test_store.py -v
Expected: 1 passed (or "skipped" if LFS not pulled — that's acceptable in CI without LFS).
- [ ] Step 4.5: Commit
git add packages/data/src/phonolex_data/runtime/store.py \
packages/data/tests/test_store.py
git commit -m "$(cat <<'EOF'
PHON-106: WordStore loads pairs.parquet
Adds pairs_df field to WordStore; from_parquet() reads
data/runtime/pairs.parquet alongside the other runtime artifacts.
Path is None if the file isn't present (back-compat for environments
without the new artifact).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Task 5: export-to-d1.py reads pairs from WordStore¶
Files:
- Modify: packages/web/workers/scripts/export-to-d1.py
The current export script reads Derived.minimal_pairs directly. Switch to reading from the new parquet via WordStore so the parquet is the single source of truth. The web's D1 schema only consumes the legacy 6 columns; drop the two new ones during the SQL emit.
- [ ] Step 5.1: Update the minimal_pairs section
Find packages/web/workers/scripts/export-to-d1.py. Locate the minimal_pairs SQL emission (around lines 169–179). The current pattern:
minimal_pairs = derived.minimal_pairs
for batch_start in range(0, len(minimal_pairs), batch_size):
...
Replace with reading from the parquet:
# PHON-106: read from data/runtime/pairs.parquet (single source of truth).
# The new parquet has 8 columns; D1 schema consumes the legacy 6.
# PHON-111 will harmonize D1 to use the continuous feature columns.
from phonolex_data.runtime.store import WordStore
store = WordStore.from_parquet(repo_root / "data" / "runtime" / "words.parquet")
if store.pairs_df is None or store.pairs_df.height == 0:
raise RuntimeError(
"data/runtime/pairs.parquet missing or empty. Run "
"packages/data/scripts/build_runtime_parquet.py first."
)
# Project to the legacy 6 columns the D1 schema expects
minimal_pairs = list(zip(
store.pairs_df["word1"].to_list(),
store.pairs_df["word2"].to_list(),
store.pairs_df["phoneme1"].to_list(),
store.pairs_df["phoneme2"].to_list(),
store.pairs_df["position"].to_list(),
store.pairs_df["position_type"].to_list(),
))
for batch_start in range(0, len(minimal_pairs), batch_size):
... # existing batched INSERT logic uses the 6-element tuples
The repo_root variable should already be defined in the script. If not, add: repo_root = Path(__file__).resolve().parents[4].
- [ ] Step 5.2: Smoke-test the export
cd packages/web/workers && uv run python scripts/export-to-d1.py
Expected: completes without error, emits d1-seed.sql. The minimal_pairs section in the SQL should match the previous output (same row count, same INSERT format).
Verify row counts match the pre-change output:
grep -c "INSERT INTO minimal_pairs" packages/web/workers/scripts/d1-seed.sql
- [ ] Step 5.3: Commit
git add packages/web/workers/scripts/export-to-d1.py
git commit -m "$(cat <<'EOF'
PHON-106: export-to-d1 reads pairs from runtime parquet
Single source of truth: pairs.parquet feeds both the Python CSP and
the D1 seed. D1 schema consumes only the legacy 6 columns; the new
feature_distance/sonorant_diff columns are projected out at SQL emit
time (PHON-111 will harmonize the web side later).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Task 6: Replace ContrastiveConstraint with three constraint types¶
Files:
- Modify: <spike>/constraint_surface.py
- Modify: <spike>/paradigm_3_csp.py
- Modify: <spike>/paragraph_csp.py
- Modify: <spike>/test_vectorized_enumeration.py
- Modify: <spike>/eval_hybrid_xcomp_ccomp.py
- Modify: <spike>/bench_enumeration.py
- Modify: <spike>/bench_domain_cache.py
- [ ] Step 6.1: Replace
ContrastiveConstraintinconstraint_surface.py
Find the existing ContrastiveConstraint dataclass in <spike>/constraint_surface.py:
@dataclass(frozen=True)
class ContrastiveConstraint:
pair_type: Literal["minpair", "maxopp"]
phoneme1: str
phoneme2: str
position: Literal["initial", "medial", "final", "any"] = "any"
type: Literal["contrastive"] = "contrastive"
Replace with three new dataclasses:
@dataclass(frozen=True)
class MinpairConstraint:
phoneme1: str
phoneme2: str
position: Literal["initial", "medial", "final", "any"] = "any"
type: Literal["contrastive_minpair"] = "contrastive_minpair"
@dataclass(frozen=True)
class MaxoppConstraint:
phoneme1: str
phoneme2: str
position: Literal["initial", "medial", "final", "any"] = "any"
min_sonorant_diff: float = 0.5
type: Literal["contrastive_maxopp"] = "contrastive_maxopp"
@dataclass(frozen=True)
class MultoppConstraint:
substitute: str
targets: tuple[str, ...]
position: Literal["initial", "medial", "final", "any"] = "any"
n_targets: int = 3
type: Literal["contrastive_multopp"] = "contrastive_multopp"
Update the Constraint union:
Constraint = (
ExcludeConstraint
| IncludeConstraint
| BoundConstraint
| BoundBoostConstraint
| MinpairConstraint
| MaxoppConstraint
| MultoppConstraint
)
- [ ] Step 6.2: Replace
cross_slot_axesto dispatch on the new types
Find cross_slot_axes in <spike>/constraint_surface.py. Currently it dispatches on c.pair_type ("minpair" / "maxopp"). Replace with:
def cross_slot_axes(
constraints: list[Constraint],
) -> dict[str, Callable[[dict[str, str], pl.DataFrame], float]]:
"""Return per-axis cross-slot scorers for legacy (post-hoc) contrastive
scoring. PHON-106 shifts MinpairConstraint and MaxoppConstraint to
linked-slot enumeration in solve_shape, so these legacy scorers are
only used by paths that haven't migrated yet (currently: none — kept
as a transition-period stub; subsequent tasks remove the call sites).
"""
axes: dict[str, Callable[[dict[str, str], pl.DataFrame], float]] = {}
# MinpairConstraint / MaxoppConstraint: handled by linked-slot mode in
# solve_shape (PHON-106). No cross-slot scorer needed.
# MultoppConstraint: deferred to paragraph integration (PHON-108).
# Returning empty dict means contrastive constraints don't add legacy
# cross-axes.
return axes
This effectively neuters the legacy cross-slot scoring for contrastive constraints. The linked-slot mode in solve_shape (Task 8) will handle them properly.
- [ ] Step 6.3: Update imports in dependent spike files
Run a search for ContrastiveConstraint across the spike directory:
grep -rln "ContrastiveConstraint" packages/generation/research/2026-05-07-sentence-generation-paradigms/
For each file that imports ContrastiveConstraint, replace the import with the three new types as needed. Most files won't actually use it after Tasks 7–9; the import can usually be removed. Check the body of each file for actual usages and update:
paradigm_3_csp.py— likely imports for type-hint purposes; if no usages remain, remove the import.paragraph_csp.py— same.test_vectorized_enumeration.py— same; existing tests don't assert ContrastiveConstraint behavior, so the import is likely removable.eval_hybrid_xcomp_ccomp.py— same.bench_enumeration.py,bench_domain_cache.py— same.
After updates, verify imports compile:
cd packages/generation/research/2026-05-07-sentence-generation-paradigms && \
uv run python -c "
import constraint_surface
import paradigm_3_csp
import paragraph_csp
import skeleton_csp
print('OK')
"
Expected: OK.
- [ ] Step 6.4: Run all spike tests to confirm no regression
cd packages/generation && uv run python -m pytest research/2026-05-07-sentence-generation-paradigms/ -v
Expected: all 65 prior tests pass (the existing minpair-cross-slot test should still pass, since linked-slot mode hasn't been wired in yet — cross_slot_axes returns empty dict for contrastive constraints, but the test in question may have been using the OLD ContrastiveConstraint via the import path and might need to be removed or rewritten in Task 9).
If a test fails referencing ContrastiveConstraint directly: remove or rewrite that test — it's testing the legacy shape we're replacing. The replacement tests land in Task 11.
- [ ] Step 6.5: Commit
git add packages/generation/research/2026-05-07-sentence-generation-paradigms/
git commit -m "$(cat <<'EOF'
PHON-106: replace ContrastiveConstraint with three constraint types
ContrastiveConstraint (single dataclass with pair_type field) is split
into MinpairConstraint, MaxoppConstraint, and MultoppConstraint. The
Constraint union extends accordingly. cross_slot_axes is neutered for
contrastive cases (returns empty dict) — the linked-slot mode in
solve_shape (next task) will handle them properly. Legacy minpair
cross-slot scoring is gone; no current test relies on it.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Task 7: Add _load_pairs_for_request helper in skeleton_csp.py¶
Files:
- Modify: <spike>/skeleton_csp.py
- Modify: <spike>/test_contrastive_scorers.py (create)
Helper: given a constraint, the WordStore's pairs_df, the filtered_spec, and an orientation flag, return a Polars DataFrame of (nsubj, dobj, feature_distance, sonorant_diff) rows ready to enter the cartesian.
- [ ] Step 7.1: Write failing test
Create <spike>/test_contrastive_scorers.py:
"""Tests for PHON-106 contrastive scorers (linked-slot enumeration)."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent))
import polars as pl
from phonolex_data.runtime.store import WordStore
from constraint_surface import (
MaxoppConstraint,
MinpairConstraint,
MultoppConstraint,
)
import skeleton_csp
@pytest.fixture(scope="session")
def store():
repo_root = Path(__file__).resolve().parents[4]
return WordStore.from_parquet(repo_root / "data" / "runtime" / "words.parquet")
@pytest.fixture(scope="session")
def sel_df():
repo_root = Path(__file__).resolve().parents[4]
return pl.read_parquet(repo_root / "data" / "runtime" / "selectional.parquet")
def test_load_pairs_for_minpair_basic(store):
"""Minpair (k, b) at initial position returns a non-empty pair frame
with the right schema."""
pairs_df = skeleton_csp._load_pairs_for_request(
constraint=MinpairConstraint(phoneme1="k", phoneme2="b", position="initial"),
pairs_df=store.pairs_df,
filtered_spec=frozenset(["cat", "bat", "kid", "bid", "key", "bee"]),
)
assert pairs_df.height > 0
assert set(pairs_df.columns) >= {"nsubj", "dobj", "feature_distance", "sonorant_diff"}
# Both halves of any returned pair must be in filtered_spec
for nsubj, dobj in zip(pairs_df["nsubj"].to_list(), pairs_df["dobj"].to_list()):
assert nsubj in {"cat", "bat", "kid", "bid", "key", "bee"}
assert dobj in {"cat", "bat", "kid", "bid", "key", "bee"}
def test_load_pairs_for_maxopp_filters_sonorant_diff(store):
"""Maxopp filters out pair rows where sonorant_diff < min_sonorant_diff."""
pairs_df = skeleton_csp._load_pairs_for_request(
constraint=MaxoppConstraint(
phoneme1="k", phoneme2="b", position="initial",
min_sonorant_diff=0.5,
),
pairs_df=store.pairs_df,
filtered_spec=frozenset(["cat", "bat"]),
)
# Every retained row has sonorant_diff >= 0.5
if pairs_df.height > 0:
for sd in pairs_df["sonorant_diff"].to_list():
assert sd >= 0.5
def test_load_pairs_emits_both_orientations(store):
"""Both (word1→nsubj, word2→dobj) and the swap are emitted."""
pairs_df = skeleton_csp._load_pairs_for_request(
constraint=MinpairConstraint(phoneme1="k", phoneme2="b", position="initial"),
pairs_df=store.pairs_df,
filtered_spec=frozenset(["cat", "bat"]),
)
nsubj_set = set(pairs_df["nsubj"].to_list())
dobj_set = set(pairs_df["dobj"].to_list())
# If both orientations emitted, both nsubj_set and dobj_set should
# include words from BOTH halves of the pair
assert nsubj_set & dobj_set, (
"Expected overlap between nsubj and dobj sets — orientations not swapped?"
)
- [ ] Step 7.2: Run tests, verify they fail
cd packages/generation && uv run python -m pytest research/2026-05-07-sentence-generation-paradigms/test_contrastive_scorers.py -v
Expected: AttributeError on _load_pairs_for_request.
- [ ] Step 7.3: Add
_load_pairs_for_requesttoskeleton_csp.py
Append to <spike>/skeleton_csp.py:
from constraint_surface import MaxoppConstraint, MinpairConstraint # noqa: E402
def _load_pairs_for_request(
*,
constraint: MinpairConstraint | MaxoppConstraint,
pairs_df: pl.DataFrame | None,
filtered_spec: frozenset[str],
) -> pl.DataFrame:
"""Filter pairs_df to rows matching the constraint and the spec lexicon.
Emits both orientations (word1→nsubj/word2→dobj and the swap).
For MaxoppConstraint, additionally filters by sonorant_diff >=
constraint.min_sonorant_diff.
Returns a DataFrame with columns: nsubj, dobj, feature_distance,
sonorant_diff. The cardinality is up to 2 * |matched pair rows|
after the spec ∩ sonorant filter.
"""
if pairs_df is None:
return pl.DataFrame({
"nsubj": pl.Series(dtype=pl.String),
"dobj": pl.Series(dtype=pl.String),
"feature_distance": pl.Series(dtype=pl.Float32),
"sonorant_diff": pl.Series(dtype=pl.Float32),
})
p1, p2 = constraint.phoneme1, constraint.phoneme2
# Match either orientation in the underlying parquet
base = pairs_df.filter(
((pl.col("phoneme1") == p1) & (pl.col("phoneme2") == p2))
| ((pl.col("phoneme1") == p2) & (pl.col("phoneme2") == p1))
)
# Position filter (skip when "any")
if constraint.position != "any":
base = base.filter(pl.col("position_type") == constraint.position)
# Maxopp pre-filter on sonorant_diff
if isinstance(constraint, MaxoppConstraint):
base = base.filter(pl.col("sonorant_diff") >= constraint.min_sonorant_diff)
# Spec lexicon intersection (both halves must survive)
base = base.filter(
pl.col("word1").is_in(list(filtered_spec))
& pl.col("word2").is_in(list(filtered_spec))
)
# Project to (nsubj, dobj, feature_distance, sonorant_diff)
forward = base.select([
pl.col("word1").alias("nsubj"),
pl.col("word2").alias("dobj"),
pl.col("feature_distance"),
pl.col("sonorant_diff"),
])
backward = base.select([
pl.col("word2").alias("nsubj"),
pl.col("word1").alias("dobj"),
pl.col("feature_distance"),
pl.col("sonorant_diff"),
])
return pl.concat([forward, backward])
- [ ] Step 7.4: Run tests, verify all pass
cd packages/generation && uv run python -m pytest research/2026-05-07-sentence-generation-paradigms/test_contrastive_scorers.py -v
Expected: 3 passed.
- [ ] Step 7.5: Commit
git add packages/generation/research/2026-05-07-sentence-generation-paradigms/skeleton_csp.py \
packages/generation/research/2026-05-07-sentence-generation-paradigms/test_contrastive_scorers.py
git commit -m "$(cat <<'EOF'
PHON-106: add _load_pairs_for_request helper for linked-slot mode
Filters pairs_df to rows matching the constraint (phoneme1/phoneme2,
position, sonorant_diff for maxopp), intersects with the spec lexicon
on both halves, and emits both orientations (word1→nsubj/word2→dobj
and swap). Returns a 4-column DataFrame ready to enter the cartesian.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Task 8: Wire linked-slot mode into _enumerate_vectorized and solve_shape¶
Files:
- Modify: <spike>/skeleton_csp.py
- Modify: <spike>/test_contrastive_scorers.py
The vectorized enumeration currently builds slot_frames independently per slot. When a contrastive constraint is present, the (nsubj, dobj) pair becomes a linked group: instead of nsubj_frame ⨯ dobj_frame, we use pair_frame directly.
- [ ] Step 8.1: Update
_should_use_vectorizedto narrow contrastive forcing
Find _should_use_vectorized in <spike>/skeleton_csp.py:
def _should_use_vectorized(*, cross_axes: dict) -> bool:
if _FORCE_PYTHON_PATH:
return False
return not cross_axes
Since Task 6 made cross_slot_axes always return empty for contrastive constraints, this function already returns True for minpair/maxopp. But MultoppConstraint should force the python fallback (deferred). Add an explicit constraint check:
def _should_use_vectorized(
*,
cross_axes: dict,
constraints: list | None = None,
) -> bool:
"""Route between vectorized and python fallback paths.
Vectorized when no cross-slot scorers are registered AND no
MultoppConstraint is present (multopp deferred to PHON-108).
"""
if _FORCE_PYTHON_PATH:
return False
if cross_axes:
return False
if constraints:
from constraint_surface import MultoppConstraint
for c in constraints:
if isinstance(c, MultoppConstraint):
return False
return True
Update the callsite in solve_shape to pass constraints. (The call currently passes only cross_axes per _should_use_vectorized(cross_axes=cross_axes). The constraints list is available higher up in solve_shape — search for where contrastive constraints are extracted, or pass the original constraints list down.)
If solve_shape doesn't currently take a constraints parameter, the contrastive constraint must be passed in via an existing mechanism (e.g., paradigm_3_csp.solve passes constraints and extracts cross_axes from them via cross_slot_axes). Check the call chain — cross_axes is built from constraints, so constraints is reachable if we thread it.
The simplest fix: have solve_shape accept an optional constraints parameter (default None) used only for routing. Existing callers don't pass it; only paradigm_3_csp.solve does.
- [ ] Step 8.2: Add linked-slot mode to
_enumerate_vectorized
In <spike>/skeleton_csp.py, find _enumerate_vectorized. After it builds slot_frames (via _build_slot_filler_tables) and BEFORE the cross-join loop, add a check for linked-slot mode:
The current cartesian construction is roughly:
cart = tables[shape.slots[0]]
for s in shape.slots[1:]:
cart = cart.join(tables[s], how="cross")
Modify _enumerate_vectorized to accept an optional pair_frame parameter. When present, replace nsubj_frame ⨯ dobj_frame with pair_frame in the cross-join chain. Updated signature:
def _enumerate_vectorized(
shape: SkeletonShape,
slot_fillers: list[tuple[str, list[str], dict[str, float], dict[str, float]]],
word_axes: dict[str, dict[str, float]],
weights: dict[str, float] | None,
locked_slots: dict[str, str],
*,
contrast_pair_frame: pl.DataFrame | None = None,
contrast_axis_name: str | None = None,
) -> pl.DataFrame:
Inside, after tables = _build_slot_filler_tables(...):
# Linked-slot mode: replace (nsubj, dobj) per-slot frames with a single
# pair frame whose rows are valid (nsubj, dobj) joint assignments.
if contrast_pair_frame is not None:
# PHON-106: pair_frame already has nsubj, dobj, feature_distance,
# sonorant_diff columns. Drop the per-slot tables for nsubj/dobj
# so the cartesian doesn't double-count them.
if "nsubj" not in shape.slots or "dobj" not in shape.slots:
raise ValueError(
"linked-slot mode requires both nsubj and dobj in shape.slots; "
f"got slots={shape.slots}"
)
# Look up PMI scores for the pair-row words against the verb's PMI table
nsubj_scores = tables["nsubj"] # used only for its pmi lookup
dobj_scores = tables["dobj"]
# Replace the joined nsubj/dobj columns with the pair frame's nsubj/dobj
# plus the pmi_nsubj/pmi_dobj joined from per-slot tables
pair_with_pmi = contrast_pair_frame.join(
nsubj_scores.select(["nsubj", "pmi_nsubj"]),
on="nsubj",
how="left",
).join(
dobj_scores.select(["dobj", "pmi_dobj"]),
on="dobj",
how="left",
).fill_null(0.0)
# Add the maxopp axis column when contrast_axis_name is provided
if contrast_axis_name is not None:
pair_with_pmi = pair_with_pmi.rename(
{"feature_distance": contrast_axis_name}
)
tables["__contrast_group__"] = pair_with_pmi
# Reorder build to start from the contrast group, then cross-join
# remaining non-(nsubj/dobj) slots
ordered_slots = ["__contrast_group__"] + [
s for s in shape.slots if s not in ("nsubj", "dobj")
]
cart = tables[ordered_slots[0]]
for s in ordered_slots[1:]:
cart = cart.join(tables[s], how="cross")
# Skip the per-slot Cartesian path
else:
# Standard mode: independent per-slot Cartesian
cart = tables[shape.slots[0]]
for s in shape.slots[1:]:
cart = cart.join(tables[s], how="cross")
Then drop the standard nsubj != dobj filter when we're in linked-slot mode (the pair list already guarantees distinct words):
# nsubj != dobj invariant — only enforce in standard mode (linked-slot mode
# already only emits valid pair rows)
if contrast_pair_frame is None and "nsubj" in shape.slots and "dobj" in shape.slots:
cart = cart.filter(pl.col("nsubj") != pl.col("dobj"))
The remainder of _enumerate_vectorized (per-word axes, adv_sentinel, total_score) operates on the merged cart — nsubj, dobj, and pmi_nsubj, pmi_dobj columns are all present whether linked-slot or standard, so the score expressions don't need modification.
- [ ] Step 8.3: Wire
solve_shapeto detect and dispatch linked-slot mode
Find solve_shape. Add (near the top, after slot_fillers is built):
# PHON-106: detect contrastive constraints and prepare linked-slot mode
contrast_pair_frame: pl.DataFrame | None = None
contrast_axis_name: str | None = None
constraints_list = constraints if isinstance(constraints, list) else []
contrast_constraints = [
c for c in constraints_list
if isinstance(c, (MinpairConstraint, MaxoppConstraint))
]
if contrast_constraints:
if len(contrast_constraints) > 1:
raise ValueError(
"at most one contrastive constraint per request"
)
cc = contrast_constraints[0]
# Validate shape: needs >= 2 nominal content slots
nominal_content = [
s for s in shape.content_slots
if s in ("nsubj", "dobj", "iobj") or s.startswith("pobj_")
]
if len(nominal_content) < 2:
raise ValueError(
"contrastive constraint needs >= 2 content nominal slots; "
f"shape={shape.arg_structure} has {len(nominal_content)}"
)
# For v1, only the (nsubj, dobj) pair is supported
if "nsubj" not in shape.slots or "dobj" not in shape.slots:
raise ValueError(
"contrastive constraint requires (nsubj, dobj) in shape; "
f"got slots={shape.slots}"
)
contrast_pair_frame = _load_pairs_for_request(
constraint=cc,
pairs_df=word_store.pairs_df if word_store else None,
filtered_spec=frozenset(domain_words),
)
if contrast_pair_frame.height == 0:
return [] # no valid pairs after filtering
if isinstance(cc, MaxoppConstraint):
contrast_axis_name = f"contrast_maxopp_{cc.phoneme1}_{cc.phoneme2}"
# Multopp constraint: error in single-sentence mode (deferred to PHON-108)
multopp = [c for c in constraints_list if isinstance(c, MultoppConstraint)]
if multopp:
raise ValueError(
"MultoppConstraint requires multi-sentence paragraph composition; "
"not implemented in v1 (deferred to PHON-108)"
)
Pass contrast_pair_frame and contrast_axis_name through to _enumerate_vectorized:
if _should_use_vectorized(cross_axes=cross_axes, constraints=constraints_list):
cart = _enumerate_vectorized(
shape=shape,
slot_fillers=slot_fillers,
word_axes=word_axes,
weights=weights,
locked_slots=initial_locks,
contrast_pair_frame=contrast_pair_frame,
contrast_axis_name=contrast_axis_name,
)
...
solve_shape needs access to the WordStore for word_store.pairs_df. The current signature passes word_df (a pl.DataFrame), not the full store. The simplest fix: have callers pass pairs_df explicitly via a new optional kwarg pairs_df: pl.DataFrame | None = None. Update paradigm_3_csp.solve() to thread it from WordStore:
In paradigm_3_csp.solve():
# near the existing word_df handling:
pairs_df = store.pairs_df if hasattr(store, "pairs_df") else None
# pass to solve_shape:
solve_shape(..., pairs_df=pairs_df)
In solve_shape's signature:
def solve_shape(
shape: SkeletonShape,
*,
verb: str,
domain_words: set[str] | frozenset[str],
sel_df: pl.DataFrame,
band: str,
word_axes: dict[str, dict[str, float]],
cross_axes: dict,
word_df: pl.DataFrame | None = None,
pairs_df: pl.DataFrame | None = None, # NEW (PHON-106)
weights: dict[str, float] | None = None,
top_k: int = 8,
locked_slots: dict[str, str] | None = None,
constraints: list | None = None,
...
(Adapt to whatever solve_shape's actual current signature is — the goal is pairs_df is reachable inside solve_shape.)
- [ ] Step 8.4: Add tests for linked-slot enumeration
Append to <spike>/test_contrastive_scorers.py:
def test_minpair_linked_slot_realization(store, sel_df):
"""Every output's (nsubj, dobj) IS a valid (p1, p2) minimal pair."""
from skeleton_csp import SkeletonShape, parse_arg_structure, solve_shape
import paradigm_3_csp
shape = SkeletonShape("nsubj,V,dobj", parse_arg_structure("nsubj,V,dobj"), 0)
spec_words = paradigm_3_csp.spec_lexicon(store, "spec1")
constraint = MinpairConstraint(phoneme1="k", phoneme2="b", position="initial")
top = solve_shape(
shape, verb="cut", domain_words=spec_words, sel_df=sel_df,
band="fineweb_adult", word_axes={}, cross_axes={},
word_df=store.df, pairs_df=store.pairs_df, top_k=8,
constraints=[constraint],
)
assert top, "expected candidates"
# Verify every (nsubj, dobj) pair in top is in pairs_df for (k, b) initial
valid_pairs = set()
for row in store.pairs_df.filter(
((pl.col("phoneme1") == "k") & (pl.col("phoneme2") == "b"))
| ((pl.col("phoneme1") == "b") & (pl.col("phoneme2") == "k"))
).filter(pl.col("position_type") == "initial").iter_rows(named=True):
valid_pairs.add((row["word1"], row["word2"]))
valid_pairs.add((row["word2"], row["word1"]))
for c in top:
nsubj, dobj = c["fillers"]["nsubj"], c["fillers"]["dobj"]
assert (nsubj, dobj) in valid_pairs, (
f"{nsubj}, {dobj} not a valid (k, b) initial minimal pair"
)
def test_minpair_single_content_slot_errors(store, sel_df):
"""Minpair on a shape with < 2 nominal content slots raises ValueError."""
from skeleton_csp import SkeletonShape, parse_arg_structure, solve_shape
import paradigm_3_csp
shape = SkeletonShape("nsubj,V,xcomp", parse_arg_structure("nsubj,V,xcomp"), 0)
spec_words = paradigm_3_csp.spec_lexicon(store, "spec1")
with pytest.raises(ValueError, match="content nominal slots"):
solve_shape(
shape, verb="want", domain_words=spec_words, sel_df=sel_df,
band="fineweb_adult", word_axes={}, cross_axes={},
word_df=store.df, pairs_df=store.pairs_df, top_k=3,
constraints=[MinpairConstraint(phoneme1="k", phoneme2="b")],
)
def test_multopp_in_single_sentence_errors(store, sel_df):
"""MultoppConstraint raises ValueError in single-sentence shape."""
from skeleton_csp import SkeletonShape, parse_arg_structure, solve_shape
import paradigm_3_csp
shape = SkeletonShape("nsubj,V,dobj", parse_arg_structure("nsubj,V,dobj"), 0)
spec_words = paradigm_3_csp.spec_lexicon(store, "spec1")
with pytest.raises(ValueError, match="paragraph composition"):
solve_shape(
shape, verb="cut", domain_words=spec_words, sel_df=sel_df,
band="fineweb_adult", word_axes={}, cross_axes={},
word_df=store.df, pairs_df=store.pairs_df, top_k=3,
constraints=[MultoppConstraint(
substitute="t", targets=("s", "ʃ", "tʃ"),
)],
)
def test_both_minpair_and_maxopp_errors(store, sel_df):
"""Two contrastive constraints in one request raises ValueError."""
from skeleton_csp import SkeletonShape, parse_arg_structure, solve_shape
import paradigm_3_csp
shape = SkeletonShape("nsubj,V,dobj", parse_arg_structure("nsubj,V,dobj"), 0)
spec_words = paradigm_3_csp.spec_lexicon(store, "spec1")
with pytest.raises(ValueError, match="at most one contrastive"):
solve_shape(
shape, verb="cut", domain_words=spec_words, sel_df=sel_df,
band="fineweb_adult", word_axes={}, cross_axes={},
word_df=store.df, pairs_df=store.pairs_df, top_k=3,
constraints=[
MinpairConstraint(phoneme1="k", phoneme2="b"),
MaxoppConstraint(phoneme1="k", phoneme2="m"),
],
)
- [ ] Step 8.5: Run tests, verify all pass
cd packages/generation && uv run python -m pytest research/2026-05-07-sentence-generation-paradigms/test_contrastive_scorers.py -v
Expected: 7 passed (3 from Task 7 + 4 new).
- [ ] Step 8.6: Smoke-test paradigm_3_csp.solve with a minpair constraint
cd packages/generation/research/2026-05-07-sentence-generation-paradigms && \
uv run python -c "
import paradigm_3_csp
from constraint_surface import MinpairConstraint
from phonolex_data.runtime.store import WordStore
from pathlib import Path
import polars as pl
repo = Path('../../../..').resolve()
store = WordStore.from_parquet(repo / 'data' / 'runtime' / 'words.parquet')
sel_df = pl.read_parquet(repo / 'data' / 'runtime' / 'selectional.parquet')
spec_words = paradigm_3_csp.spec_lexicon(store, 'spec1')
top, stats = paradigm_3_csp.solve(
'cut', 'spec1', spec_words, sel_df,
constraints=[MinpairConstraint(phoneme1='k', phoneme2='b', position='initial')],
word_df=store.df,
)
print(f'top-1: {top[0][\"sentence\"]!r}')
print(f'fillers: {top[0][\"fillers\"]}')
"
Expected: prints a sentence whose nsubj and dobj fillers form a real (k, b) initial minimal pair (e.g., "the cat cuts the bat").
- [ ] Step 8.7: Run all spike tests
cd packages/generation && uv run python -m pytest research/2026-05-07-sentence-generation-paradigms/ -v
Expected: 65 prior + 7 new = 72 passed.
- [ ] Step 8.8: Commit
git add packages/generation/research/2026-05-07-sentence-generation-paradigms/skeleton_csp.py \
packages/generation/research/2026-05-07-sentence-generation-paradigms/paradigm_3_csp.py \
packages/generation/research/2026-05-07-sentence-generation-paradigms/test_contrastive_scorers.py
git commit -m "$(cat <<'EOF'
PHON-106: linked-slot mode in solve_shape for minpair/maxopp
solve_shape detects MinpairConstraint or MaxoppConstraint, validates
that the shape has both nsubj and dobj content slots, loads matching
pair rows from pairs_df, and passes them into _enumerate_vectorized
as a contrast_pair_frame. _enumerate_vectorized replaces the
independent (nsubj, dobj) slot Cartesian with the pair frame —
guaranteeing every output candidate's (nsubj, dobj) is a valid
minimal pair contrasting the requested phonemes.
MaxoppConstraint additionally pre-filters by sonorant_diff and adds a
contrast_maxopp_<p1>_<p2> = feature_distance score component.
MultoppConstraint and shapes without (nsubj, dobj) raise ValueError.
Two contrastive constraints in one request also raise.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Task 9: Edge case tests + documentation polish¶
Files:
- Modify: <spike>/test_contrastive_scorers.py
Catch the remaining edge cases and add a maxopp-specific test for the feature_distance score component.
- [ ] Step 9.1: Add tests
Append to <spike>/test_contrastive_scorers.py:
def test_maxopp_feature_distance_in_components(store, sel_df):
"""MaxoppConstraint adds contrast_maxopp_<p1>_<p2> = feature_distance to components."""
from skeleton_csp import SkeletonShape, parse_arg_structure, solve_shape
import paradigm_3_csp
shape = SkeletonShape("nsubj,V,dobj", parse_arg_structure("nsubj,V,dobj"), 0)
spec_words = paradigm_3_csp.spec_lexicon(store, "spec1")
constraint = MaxoppConstraint(phoneme1="k", phoneme2="m", position="initial")
top = solve_shape(
shape, verb="cut", domain_words=spec_words, sel_df=sel_df,
band="fineweb_adult", word_axes={}, cross_axes={},
word_df=store.df, pairs_df=store.pairs_df, top_k=3,
constraints=[constraint],
)
if not top:
pytest.skip("no (k, m) initial pairs in spec1 — adjust probe verb")
axis_name = "contrast_maxopp_k_m"
for c in top:
assert axis_name in c["score_components"]
# Feature distance is positive for any actual pair
assert c["score_components"][axis_name] > 0
def test_pairs_empty_after_filter_returns_no_candidates(store, sel_df):
"""If pair_rows are empty after spec ∩ sonorant filter, solve_shape returns []."""
from skeleton_csp import SkeletonShape, parse_arg_structure, solve_shape
import paradigm_3_csp
shape = SkeletonShape("nsubj,V,dobj", parse_arg_structure("nsubj,V,dobj"), 0)
# Use an unlikely (p1, p2) pair to force empty
spec_words = paradigm_3_csp.spec_lexicon(store, "spec1")
constraint = MinpairConstraint(phoneme1="zh", phoneme2="dh", position="initial")
top = solve_shape(
shape, verb="cut", domain_words=spec_words, sel_df=sel_df,
band="fineweb_adult", word_axes={}, cross_axes={},
word_df=store.df, pairs_df=store.pairs_df, top_k=8,
constraints=[constraint],
)
assert top == [], f"expected empty top for unlikely pair, got {len(top)} candidates"
def test_minpair_uses_vectorized_path():
"""MinpairConstraint no longer forces python fallback (PHON-104 fix
routed it to fallback; PHON-106 routes it to vectorized linked-slot)."""
import skeleton_csp
# cross_axes is empty for new-style contrastive constraints
cross = {}
constraints = [MinpairConstraint(phoneme1="k", phoneme2="b")]
assert skeleton_csp._should_use_vectorized(
cross_axes=cross, constraints=constraints
) is True
def test_multopp_forces_python_fallback_in_routing():
"""MultoppConstraint routes to python fallback (deferred to PHON-108)."""
import skeleton_csp
cross = {}
constraints = [MultoppConstraint(substitute="t", targets=("s",))]
assert skeleton_csp._should_use_vectorized(
cross_axes=cross, constraints=constraints
) is False
- [ ] Step 9.2: Run tests, verify all pass
cd packages/generation && uv run python -m pytest research/2026-05-07-sentence-generation-paradigms/test_contrastive_scorers.py -v
Expected: 11 passed (7 prior + 4 new).
- [ ] Step 9.3: Run all spike tests
cd packages/generation && uv run python -m pytest research/2026-05-07-sentence-generation-paradigms/ -v
Expected: 76 passed total (65 prior + 11 new contrastive).
- [ ] Step 9.4: Commit
git add packages/generation/research/2026-05-07-sentence-generation-paradigms/test_contrastive_scorers.py
git commit -m "$(cat <<'EOF'
PHON-106: edge-case tests for contrastive linked-slot mode
Adds: maxopp feature_distance component verification; empty-after-
filter returns []; routing correctness (minpair → vectorized,
multopp → python fallback).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
EOF
)"
Done¶
After Task 9 commits, PHON-106 v1 is complete. PHON-108 (paragraph integration for multopp) is unblocked. PHON-111 (web app continuous-vector parity) is filed and independent.