Skip to content

[WIP] Canonical v2 migration#80

Open
sergeyf wants to merge 81 commits into
mainfrom
canonical-v2-migration
Open

[WIP] Canonical v2 migration#80
sergeyf wants to merge 81 commits into
mainfrom
canonical-v2-migration

Conversation

@sergeyf

@sergeyf sergeyf commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

sergeyf and others added 16 commits July 4, 2026 13:05
- work_plan §2: 2026-07-04 reachability audit note; refresh drifted line
  references; move the same-signature paper_author_list item to Tier B
  (the residual-summary training path already excludes the query)
- normalization_migration_blocked: unblocking status, Open Decisions 3-4
  (benchmark training-data names; single-mode cutover), implementation
  notes from the review, gate re-baselining protocol, threshold scope
- production_inference: new bundles must store runtime eps in
  clusterer.json; do not extend the v1.2 override list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- tests/fixtures/canonical_name_examples.json: 41 cases across
  hyphen/space given names, apostrophe variants, initials, compound and
  particle surnames, title prefixes, transliteration, and missing-field
  edges; each case carries the proposed canonical_v2 fields + count
  keys, the computed legacy values, and references into a decisions
  registry (D1-D8, decided vs open)
- tests/test_canonical_name_examples.py: pins the legacy values against
  the current normalizer and count-key shims (runs now, guards against
  legacy drift on this branch), checks table coherence (equivalence
  groups, decision references, normalized-form invariants), and asserts
  the canonical contract against canonicalize_name_parts /
  canonical_name_count_keys (skipped until migration step 2 lands)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- D1 (ruled): spill on space, keep together on dash - the current
  dash-aware splitter is confirmed as canonical policy; cross-variant
  compatibility stays compare-time via same_prefix_tokens
- D3 (ruled): all apostrophe-like marks including backtick are removed
  from name fields
- D6 (refined): count keys are the canonical fields verbatim, the
  single-char informative gate is unchanged, missing components yield
  null keys instead of sentinel counts
- Fixture regenerated: field-level canonical deltas now reduce to the
  apostrophe cases only; count-key deltas to decompaction + nulls
- Migration doc: rulings recorded in the Decided list; step 1 marked
  complete with a pointer to the fixture

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Legacy count keys now asserted through the LIVE
  ANDData._compute_signature_name_counts path with seeded count dicts
  (drift in the real method fails the test), replacing the mirrored
  key-construction helper
- Add tested compare-time contract: canonical firsts across Jo/Jo Ann/
  JoAnn-style variant groups must be pairwise same_prefix_tokens-
  compatible, with negative pairs guarding against degenerate
  compatibility
- D6 reframed explicitly as a feature-semantics change (null keys for
  missing components change live lookups) gated by the retrain
  re-baseline eval; D7 prefix-only edge worded as cleanly decided;
  migration doc Decided list aligned

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prescribed, decision-free fixes from the deferred correctness batch, with the
one genuine policy call (detect_language single-detector reliability) left open.

Parity pairs (both implementations):
- self-cite: guard on paper_id_1 != paper_id_2 (featurizer.py, rust_featurizer.rs)
- email: absent '@' -> None suffix / NaN feature (text.email_prefix_suffix, features.email_parts)
- equal_middle: single initial vs the set of all token initials (text.py, features.middle_names_equal)
- whitespace-only equal: strip-then-test-empty (text.equal, features.first_names_equal)
- compute_ref: reference-list features computed without reference_details;
  only the 4 ngram counters still gated (featurizer.py, rust_featurizer.rs)

Python-only:
- ORCID pattern uses [0-9] (reject Unicode digits), matching Rust
- empty-surname name counts return np.nan for last-dependent keys (D6)
- get_text_ngrams short-token filter decoupled from stopwords
- apply_sinonym_overwrites invalidates derived normalized/name-count fields
- same-signature guard moved above paper_author_list_* updates (Tier B)

Rust-only:
- raw_arrow readers route required string columns through required_value
- extract_specter_vec keeps all-zero rows as present (matches Arrow ingest)

FEATURIZER_VERSION 3->4 (invalidates the pair-feature cache). New coverage in
tests/test_text.py and tests/test_correctness_pass.py; canonical fixture's
legacy count keys now use None for no-lookup components via the live
ANDData path. work_plan section 2 records the pass and the remaining open item.

Verified: 343 python tests + 23 parity/correctness tests + 28 rust unit tests
pass; ruff clean; maturin build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build_name_counts_data_from_artifact defaulted last / first_last /
last_first_initial to the sentinel 1.0 on a lookup miss, with no guard for an
empty surname. After the 2026-07-04 Python fix made
ANDData._compute_signature_name_counts return np.nan for every last-dependent
key when the surname is empty (D6), the two implementations diverged: for an
empty-surname signature routed through the Rust staged-preprocessing artifact
path, Python reported NaN while Rust reported a corpus count of 1. The existing
parity tests missed it because none exercise an empty surname through this path.

Add the same first_informative / last_present gates the Python method uses, so
an empty surname yields NaN in both. New self-contained Rust regression test
(empty_surname_yields_nan_matching_python) builds a zero-record on-disk index
so has_data() is true and drives both an empty- and present-surname case.

Verified: 29 rust unit tests pass; 154 python parity/name-count/correctness
tests pass after maturin rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 2026-07-04 pass routed the required Arrow string columns (author
first/middle/last/suffix, title/venue/journal_name) through required_value,
which ERRORS on NULL. That was a false premise: the Arrow producer's
_optional_str maps both "" and None to Arrow NULL, so NULL is the normal
representation of an empty value -- author_suffix is ~100% NULL in real
bundles and empty surnames are valid per D6. required_value would raise on
essentially the first row of every real dataset; parity fixtures missed it
because they hand-write "" instead of NULL.

Revert those 7 columns to optional_owned().unwrap_or_default() (NULL -> ""),
restoring Python<->Rust parity (Python coerces None -> "" via normalize_text).
Add a self-contained Rust round-trip test
(null_paper_strings_read_as_empty_not_error) building a papers batch with NULL
title/venue/journal_name and asserting they read back as "". abstract stays
optional. No FEATURIZER_VERSION change (feature values unchanged).

Verified: 30 rust unit tests; 97 python parity/arrow-ingest tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 2026-07-04 correctness pass moved the `if same_signature: continue` guard
above the paper-author-list updates (the 'Tier B hardening' item), so a query
that is a member of its own candidate cluster stopped contributing to the
paper_author_list_* features. That broke a pre-existing, passing test,
tests/test_query_adapter.py::test_local10_evidence_ignores_query_signature_member,
which deliberately locks the intended behavior: paper-author-list features DO
include the self-match (jaccard 1.0); only the local10 window excludes the
query signature. The correctness-pass verification never ran
test_query_adapter.py, so the regression slipped through.

The guard was speculative hardening for a caller that does not exist
(production incremental linking and linker training both drop the query
upstream, so no live path feeds a query-inclusive summary) -- which CLAUDE.md
disallows. Restore the original placement: skip same_signature only for the
local10-window features. The work_plan section-2 note and the Tier B entry are
corrected in the accompanying docs update.

Verified: test_query_adapter.py 13 passed; full suite 1137 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part of the v1.3 canonical-name migration: canonical upstream normalization
replaces the Sinonym per-paper detect-and-overwrite step, so the Sinonym code
path, its dependency, and its tests are removed.

- s2and/data.py: drop _SINONYM_DETECTOR/_SINONYM_DETECTOR_LOCK, the
  use_sinonym_overwrite ANDData option, and apply_sinonym_overwrites
  (~440 lines net).
- s2and/text.py: reword a Sinonym comment to 'normalized'.
- pyproject.toml / uv.lock: drop the sinonym dependency.
- scripts (_rust_suite/*, analyze_giant_block_subblocking, convert_to_arrow,
  linker_train_calibrate_eval, tutorial, verification/*) and
  incremental_linking_training/data_loading.py: drop the use_sinonym_overwrite
  argument.
- tests: delete test_sinonym_overwrite.py and test_sinonym_detector_lock.py;
  update test_correctness_pass, test_data, test_canonical_name_examples,
  test_preprocess_papers_parallel_defaults, test_feature_port_parity,
  test_rust_from_dataset_contract, test_surname_hyphen_aware to drop sinonym.
- docs/normalization_migration_blocked.md, docs/rust/arrow_dataset_spec.md:
  reword Sinonym references toward canonical normalization.

No 'sinonym' references remain anywhere in the tree; Rust never carried a
Sinonym path, so there is no Python<->Rust parity gap.

Verified: ruff clean; full suite 1137 passed, 83 skipped, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… revert

work_plan.md accumulates several updates from this session that share the file:
- Arrow reader NULL-strictness revert rationale (NULL is the legitimate empty
  value; required_value would raise on real bundles).
- Decided language-detection changes: fastText mandatory in production
  (load failure raises; testing off-switch retained); is_reliable requires both
  detectors to agree, non-agreement collapses to predicted_language='un'.
- Corrected D6 empty-surname note: the Rust artifact-build path was a latent
  divergence, now mirrored and regression-tested.
- Same-signature paper_author_list guard (Tier B) recorded as attempted then
  reverted / won't-fix (speculative guard; broke a pre-existing test).
- Sinonym-removal doc cleanups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(v5)

Implements the two decided language-detection changes, Python and Rust in lockstep.

#2 fastText mandatory in production: _get_fasttext_model raises on a genuine
load failure instead of silently degrading to a cld2-only path. The testing
off-switch (S2AND_SKIP_FASTTEXT / set_fasttext_loading_enabled) is retained.

#4 is_reliable requires agreement: a pure reconcile_detected_languages
(s2and.text and s2and_rust/src/language_detection.rs) returns is_reliable=True
only when BOTH detectors return a concrete language AND agree; any
non-agreement (disagreement, or only one detector responding) collapses to
predicted_language='un', is_reliable=False, preserving the invariant
is_reliable <=> language != 'un'.

FEATURIZER_VERSION 4->5 (affects the is_reliable-sum, predicted_language
equality, and english_or_unknown_count features).

Tests:
- reconcile_detected_languages truth-table unit tests in Python
  (tests/test_text.py) and Rust (language_detection.rs::reconcile_tests).
- test_detect_language now asserts the disabled-fastText behavior (unreliable /
  'un'); the failed-load test asserts the raise.
- test_rust_from_dataset_contract: skip guard catches the raise; titles swapped
  to ones both detectors agree on (short titles collapse to 'un' as cld2
  abstains), keeping the English-vs-German parity check.
- test_eval_prod_models B3 (1.0, 0.816, 0.899) -> (1.0, 0.815, 0.898): the
  ~0.001 shift is the not-yet-retrained production model reacting to the new
  language features; regenerate at the v1.3 retrain.

The production Arrow bundle stores Python-computed is_reliable/predicted_language
columns read verbatim by Rust, so this only takes full effect once the bundle
is regenerated (the v1.3 retrain does this).

Verified: cargo test 34 passed; full suite 1141 passed, 83 skipped, 0 failed; ruff clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Arrow readers pin name-count last_first_initial keys to initial_char
(raw_arrow_features.rs, rust_featurizer.rs) as a hardcoded assumption. A bundle
built from a legacy_full_first_token ANDData would silently produce keys Rust
reads with the wrong form and no downstream diagnostic -- a real risk since the
v1.3 retrain regenerates the bundle. feature_block_from_anddata (the single
ANDData->Arrow funnel) now raises if the source dataset declares a non-
initial_char semantic, closing work_plan section-2 item #238. Negative test
added; the existing mini-contract test covers the initial_char (pass) path.

Verified: tests/test_feature_block.py from_anddata tests pass; ruff clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nsistent)

work_plan section-2 #229 (subblocking ORCID gating asymmetry) is resolved as
already-consistent, not a parity bug: a code trace shows both implementations
gate ORCID on the per-request orcid_enabled flag, not field presence -- Rust
raw_arrow_features.rs gates orcid_hash on orcid_enabled, Python
query_adapter.mask_query_features sets orcid=None when disabled, and the
subblocking repair pass uses the same use_orcid_subblocking flag in both. All
field x flag combinations are consistent, so there is no contradictory combo to
guard at runtime; the parity test is the guard.

Adds test_orcid_enabled_false_suppresses_populated_orcid_field: with a
populated, valid author_info_orcid, orcid_enabled=False suppresses ORCID at both
the query-feature (extract_query_features) and cluster-summary layers -- pinning
that the flag, not the field, is authoritative (guards against re-introducing a
field-presence-only gate on the Python side).

Verified: tests/test_query_adapter.py passes; ruff clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sergeyf

sergeyf commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closes #39

sergeyf and others added 13 commits July 7, 2026 22:45
Address strict Rust prediction cleanup, Arrow training Specter loading, Rust featurizer cache invalidation, production model version warnings, CI no-sync behavior, transfer-mini compare ingest validation, and Rust LightGBM parser guards.

Verification: uv run --no-sync ruff check/format on touched files; uv run --no-sync pytest focused regression set; uv run --no-project cargo test --manifest-path s2and_rust/Cargo.toml lightgbm_booster; uv run --no-project cargo fmt --manifest-path s2and_rust/Cargo.toml --check; git diff --check.
Reject null Specter embeddings, remove retired reference_features from archived scripts, align preprocess benchmark coauthor ngrams, warn on auto Rust prediction downgrade, centralize class-0 prediction, use dataclasses.replace for backend downgrade, and reuse shared Arrow validation helpers.

Verification: uv run --no-sync ruff check on touched files; uv run --no-sync ruff format --check on touched files; uv run --no-sync pytest -q tests/test_arrow_training_ingestion.py tests/test_regression_fixes.py tests/test_predict_and_combine.py tests/test_incremental_linking_runtime.py; git diff --check.
The canonical_v2 cutover removed the _lasts_equivalent_for_constraint
shim and left the within-block last-name constraint comparing canonical
lasts exactly in Python, while Rust still compared space-insensitively —
a cross-language divergence, and the exact form would veto joined-vs-
spaced surname variants ("ouyang" vs "ou yang") that upstream blocking
deliberately groups under one block key.

Ruling: space-insensitive comparison is deliberate canonical_v2
compare-time policy (storage stays spaced per D5), not a legacy shim.
Adds s2and.text.canonical_lasts_equivalent, wires it into
ANDData.get_constraint, documents the Rust mirror, re-pins the
constraint tests, and records the ruling in the migration doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sergeyf and others added 30 commits July 20, 2026 09:21
Delete the per-pair SQLite cache and use_cache plumbing; add
s2and/feature_cache.py cached_featurize (per-split compressed NPZ,
fail-closed content fingerprint over ingested source bytes, name-count
binding, featurizer identity, and exact resolved pair lists; write-once
under file lock). Clusterer config v3->v4 drops the use_cache key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Require pairwise_bundle_binding as an explicit argument on
save_incremental_linking_artifact and metadata build; persist only the
validated top-level field. New audit_metadata rejects the reserved key;
legacy artifacts with an old nested audit copy load it as inert
historical metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recover the approved v2->v3 unit from the phase2 stash: the generator
counts dropped duplicate canonical pairs, and both the Python and Rust
loaders require generation counts plus output cardinality to equal the
input cardinality (v2 metadata left 3,768 of 9,925 input pairs
unaccounted). Counts validate as unsigned 64-bit integers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recover the one-line tests/test_data.py hunk that belongs to the
name-tuple v3 unit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delete the one-off study (two CLIs, scripts/_pair_ablation/,
docs/pair_ablation.md, five test files) per the 2026-07-20 owner
decision. The complete study is preserved verbatim in the ignored
scratch/pair_ablation_study/ workspace with a provenance README, and
git history retains the tracked copies before this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recover the target-identity unit from the phase2 stash, adapted to the
explicit pairwise-binding API: metadata schema v4 adds a required
target_spec_digest (validated as SHA-256), save_incremental_linking_artifact
requires the target spec, and both bundle finalization and production
loading reject a target JSON whose canonical digest differs from the
artifact — including a target modified after manifest checksums are
refreshed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recover the fixture/config hardening hunks from the phase2 stash: pin
fixture tolerances to a shared constant and reject relaxed values,
require finite [0, 1] fixture probabilities, reject Boolean numeric
config values, read bundle_status as a required manifest field, and
validate promoted target JSON structure including duplicate features.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derive the linker's expected pairwise binding from the manifest and
clusterer config the load already validated instead of re-running
pairwise_bundle_binding over the whole bundle, which re-hashed every
declared file. Public pairwise_bundle_binding(path) keeps its validating
boundary. A regression asserts one checksum per declared file on a
normal complete-bundle load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IncrementalLinkingArtifactMetadata.__post_init__ already validates the
contract on construction; the explicit call after build() re-validated
the same object. The staged reload, atomic staging, fsync, locks, and
manifest hashing are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add production-entrypoint tests showing predict_from_arrow_paths and
predict_incremental_from_arrow_paths raise MissingArrowArtifactError
when the raw-planner batch indexes are absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner decision 2026-07-20: keep the counts and index publishes as two
sequential individually-atomic operations instead of a transactional
pointer. A crash between them is fail-closed at consumption — the index
manifest embeds the counts generation provenance and every consumer
validates the four-field name-count binding — and rerunning generation
with --overwrite repairs the tear. Add a real integration test that
injects the crash, inspects the torn on-disk state, proves the binding
rejects it, and proves the rerun repairs it. The stashed
callback/rollback candidate stays unrecovered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delete scripts/bench_python_name_counts.py and the in-file Rust
benchmark harness that recreated the retired dense signature-index
layout. The adaptive-layout rationale is recorded in docs/rust/runtime.md;
the real layout-selection unit tests remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove model._missing_arrow_prediction_artifacts_error,
production._summarize_query_views / promoted_incremental_observed_probe /
_raw_arrow_plan_window_size / _raw_arrow_plan_windows,
policy.arrow_paths_have_name_counts_index,
subblocking._load_canonical_orcid_prefix_counts, and
ANDData._compute_signature_name_counts. Useful assertions moved to live
paths first: ORCID loader tests go through
_LazyCanonicalOrcidPrefixCounts.load(), and canonical count-key coverage
runs through the batched preprocess_signatures count path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove the six dead serde derives with their field attributes and
unused imports (compiler-verified), drop the docstring reference to the
gitignored canonical-example generator, remove the stale optional
comment on the enabled aarch64 matrix entry, and rewrite the
release-workflow policy tests to assert on parsed YAML structure
instead of raw-text regex and string counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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