Changelog
Version 2 · Add the 7 September 2026 documentation entry: HANDOVER 3.0.0, RUNBOOK 2.0.0, new DECISIONS.md, README 1.2.0, and the missing ANALYSIS document recorded rather than reconstructed.
Changelog
Consolidated history of BreedingInfill, newest first. Entries dated 23–25 August
2026 come from the retired RQDG/BreedingInFill2 repository and are folded in
here so the record is continuous.
2026-09-07 — Documentation brought up to the rebuilt pipeline (project 0.4.1)
HANDOVER.md and RUNBOOK_BREEDING_BUILD.md both still described schema v3 with
key_name, the eight-phase breeding_build.py command set, and an rs.breeding
holding 88 proving rows. All three statements had been wrong since 5 September.
README.md carried a "read first" note pointing at rebuild/, but the two
documents a person actually opens to resume work did not.
HANDOVER.md3.0.0. Rewritten for schema v4 and therebuild/pipeline, with live state verified against both servers. Open work is now eight numbered items. Previous version archived atarchive/HANDOVER_v2.0.0.md.RUNBOOK_BREEDING_BUILD.md2.0.0. Rewritten around the eight stages of the current build. Carried forward the parts of v1.6.0 that are still true and hard to reconstruct — why the build is local rather than direct to QDB, the country-id CRC verification, theracing_codeprovenance correction, and the id-preservation rule — and dropped the stage sequence that no longer exists. Previous version archived atarchive/RUNBOOK_BREEDING_BUILD_v1.6.0.md.DECISIONS.md1.0.0, new. The decision register thatarchive/breedinginfill2/README.mdrefers to and which did not exist in either repository. Eighteen decisions (D1–D18) with the measurement or the call behind each, seven engineering rules (E1–E7), and the list of what is deliberately absent. Rule identifiers R1–R11 are unchanged and used verbatim by the code.README.md1.2.0. The "Core Rules" section had still describedkey_nameas the identity key and referenced the retired staging path.
Recorded, not invented
ANALYSIS_newAUData_dump.mdis missing. Cited byload_studbook.py(§2c, decision 12) anddedup_adjacent_years.sql(§4.6 — the season rule and the 1 August → 1 July boundary move at 2000, 99.9876 % over 911,178 rows). Not in this repository and not in BreedinginFill2. Its figures are quoted inDECISIONS.mdfrom the code comments that cite them; the working is not reproduced, and no attempt was made to reconstruct it.- The legacy decision codes the code cites — C1, C4a, C5, F5 — come from that
same missing register.
DECISIONS.mdrenumbers to D1–D18 and cross-references each legacy code where the source cites one, so the comments in the code stay traceable.
Also
Published this project to the QDG Knowledge Base as BreedingInfill: overview, data sources, breeding rules, breeding table schema, pipeline and operations, the August 2026 validation, status and open items, and this changelog. All figures taken against the live databases on 7 September.
2026-09-06 — Repository consolidated (project 0.4.0, commit b299932)
BreedinginFill2retired. Work had been split across two repositories since 23 August.RQDG/BreedingInFillis now the single source of truth. Everything the second repository contained is preserved underarchive/breedinginfill2/with a README mapping each file to what replaced it.- Six files had never been committed anywhere — including
src/breeding_load.py(the 724-line two-pass loader) and both pilot reports. They were committed toBreedinginFill2first (d4daaf4) so that repository's history is complete, then copied across. Nothing was lost. .gitignorewas excluding deliverables.reports/was excluded as a directory, which makes any negation inside it impossible — so the August validation reports could never be tracked and had been sitting untracked on disk. Changed toreports/*plus!reports/*.docx. Added!archive/**/*.sqlso archived schema and SQL keep their provenance.- Decided against database triggers for the lock.
add_lock_triggers.sqlwas one way to enforceis_lockedonrs.breeding. The decision was to enforce it in a single write gateway instead — one routine with all the checks, through which every update, insert and delete passes. The file is kept unrunnable and clearly headed atarchive/add_lock_triggers_NOT_ADOPTED.sql. It was never applied to either server.
2026-09-05/06 — The rebuild (commits 3584aab, d3d1776)
Replaced the eight-phase staging pipeline (226 GB of intermediate tables) with
load-then-link. breeding now holds 3,106,726 rows, one per identity on
(publish_name, country_id, year_of_foal), with 2,895,470 horses carrying a
complete five-generation pedigree.
New pipeline — rebuild/
| File | Role |
|---|---|
common.py, linking.py |
Shared helpers and the rule B statements |
preflight_rebuild.py |
Environment, schema, source-data and program checks; exits 2 on blockers |
load_pq.py |
parsed_horses → breeding (step 1) |
load_studbook.py |
newAUData → breeding, linking as it loads (step 2) |
link_parents.py |
Rule B over the whole table (step 3) |
reports.py |
Identity conflicts, unlinked parents, anomalous links (step 4) |
dedup_adjacent_years.sql |
Duplicate identity resolution |
reconcile_sources.py |
Two-source agreement, check and lock |
ship_to_qdb.py |
Id-for-id copy to QDB, resumable and verifiable |
Driven by run_breeding_rebuild.bat, dry run unless given apply. Full build
timing on 5 September: PQ load 1.6 min, studbook load 28.9 min, link 0.3 min.
Decisions taken
sexis derived fromis_male; the source vocabulary is not stored, because the studbook cannot tell a stallion from a gelding. Measured on 66 matched AU/NZ males, 33 saidstallionwhere the run records saidgelding. Retired thetblhorsegelding lookup and thegelded_from_tblhorse/male_genericcounters. (Robert, 5 September.)- A row with progeny is never deleted automatically. Duplicate pairs whose loser has children are marked for review and both rows left untouched. (Robert, 5 September.)
- Linking within a source may ignore the year; matching across sources requires it, allowing +1 for northern-hemisphere parents only.
Duplicate resolution
5,052 adjacent-year pairs found; 2,301 also shared both parent names. Verdicts:
1,233 MERGE, 537 held back because the duplicate has progeny (1,888 children
involved), 474 with no foal date to arbitrate, 28 where the date contradicts its
own row, 18 chains of three or more consecutive years, 9 where both rows are
dated, 2 descriptor names (WILDAIR MARE and similar, a known false-positive
class).
Validation
Tested against race-day runners for 1, 2, 8 and 9 August 2026: 2,963 runners,
5,926 stated parents, no error identified in breeding. Reports committed as
reports/Breeding_validation_August_2026.docx.
Fixed
.gitignoreblanket*.sqlhad been excluding the schema definitions and the pipeline SQL along with data dumps — so the schema for the table this project builds was never under version control. Negations added.run_breeding_rebuild.batkilled by its own log output. A log line containing->, a pipe and parentheses reachedcmd's parser. Tail output now goes through PowerShell, which writes to the console and appends to the summary itself;cmdnever sees the text. Banner assignments quoted so->is not read as a redirect.
2026-08-27 — Schema v4: key_name dropped
key_name, sire_key_name and dam_key_name removed from breeding. Identity
became the triple (publish_name, country_id, year_of_foal), enforced by
uq_breeding_identity. mention_count and updated_at retained. The loaders
refuse to run against a schema that still carries the dropped columns.
2026-08-25 — Studbook cross-check (BreedinginFill2 v0.6.0)
- Cross-checked a 50-horse random sample (foaled 2000+) from
studbook.org.auagainst the build, read-only. Most "not found" horses simply had not been loaded yet or needed punctuation-insensitive matching. studbook_profiles.dam_countryhas systemic corruption — roughly 17,750 of 235,384 rows hold date strings instead of country codes.- Isolated the northern-hemisphere year offset to the foal page, not the
parent's record. A consistent one-year offset appears in the Studbook
foal-page
sire_year/dam_yearfields for GB/USA/IRE parents. Robert asked whether the parent's own profile shows the same shift — it does not. Worked example on CHOPPA's page: dam BOSSINOVA (AUS) agrees everywhere; sire AMADEUS WOLF (GB) shows 2002 on the foal page but 2003 on his own profile and inbreeding. Same pattern on FINETTI's page for SHOP AROUND (GB). Zero build impact, and the origin of the season-year rule. - Found a bug that existed only in the saved script.
LOAD_RULESfiltered on the raw pre-sanitizepublish_namefor both R4 (length) and R5 (ONMOUSE), in theWHEREclause ahead of the sanitizer in theSELECT. For a horse with only one corrupted mention this would silently drop the identity. The ad-hoc SQL actually run throughout the build did not have it. Fixed by moving the length check toHAVINGagainst the sanitized name. - Build advanced to 1,560,531 rows, cursor
NESTOR VIVE, ~52 % of target. Re-ran LINK: +256,355 sire / +276,877 dam links, zero R14 refusals.
2026-08-24 — Pilot hardening (BreedinginFill2 v0.3.0–v0.5.0)
- Schema v4 applied on both
rs.breedingandqdb.breeding: addedsire_key_name,dam_key_name,mention_count,updated_at. 26 columns, identical ordinal positions, no drift between servers. - Chunking bug fixed. Chunking by
idrange splits one horse's mentions across chunks, makingCOUNT(*)a partial count and breakingmention_count;LIMIT-then-recover-cursor-via-MAX(key_name)silently skips data oncebreedingholds a row sorting past the chunk. Fixed by chunking onkey_namerange with the boundary computed first by an index-only query, then loading the closed range with noLIMIT. Also faster: 450 identities/sec against 250. - Scraper artefact in
publish_name. 6,378 of 71.8 Mparsed_horsesrows carry the name duplicated plus a stray HTML attribute fragment. One instance at 59 characters hard-failed a LOAD chunk. Handled by detecting theONMOUSEOUTmarker, stripping it and recovering the real name from theNAME NAMEduplication. payload_jsonreturns the literal string"null", not SQL NULL, when a horse has no named sire —CAST(... AS UNSIGNED)then errors. Fixed withNULLIF(...,'null'). Recorded as fixed in v0.4.0 but not actually saved to the file; found again the next day. Lesson recorded at the time: verify the live file, do not trust the changelog's description of a past fix.- Rule R14 added (since superseded): LINK required the matched parent row's own year and country to agree with what the child's page stated, refusing the link rather than the row. Zero false refusals on a 43,784-identity chunk.
- Rule R13 added: an identity collision is a duplicate candidate only when both parent keys also match. Cut the review queue from 60 groups to 14 on a 60,000-row sample.
- Manually walked a real 10-horse pedigree one insert at a time (PINK TERRACE → ALL PINK2 → ALL RED → STEPNIAK → NORDENFELDT → MUSKET/ONYX2), confirming links resolve as each generation loads and stay correctly NULL for ancestors not yet present.
- Cross-checking a PedigreeQuery family chart found 5 names (ATALANTA,
CLEMENTINA, GYMKHANA, PETAL, WAR DANCE) whose plain
key_nameresolves to a completely different horse — the correct identity needed its PQ suffix. This is the evidence thatkey_nametext alone is not reliably matchable to a real-world identity, and it fed directly into the 27 August decision to drop it. - Chunk size dropped 50,000 → 10,000: a 50,000-identity LOAD reported success and inserted zero rows, silently cancelled by the MCP bridge's 60-second client timeout. Also observed the timeout is not consistent — of three consecutive timeouts, two had committed server-side and one had not. Always verify count/cursor after a timeout.
2026-08-23 — The two-pass design (BreedinginFill2 v0.1.0–v0.2.0)
ANALYSIS_current_pipeline.md— measured review of the eight-phase pipeline. Key findings: 226 GB of staging produces a 0.4 GB table;parsed_horsesis 96 % repeated mentions (23.6 per horse); the parent's own row agrees with the child'spayload_jsonon 989,090 pairs to 99.997 %; one named parent in 24,524 has no row of its own.DESIGN_breeding_load.md— the two-pass design: LOAD then LINK, twelve rules down from twenty-four, no staging tables, no tiers, no confidence score. Still the best statement of why the pipeline has the shape it does.- Pilot run: 1,408 identities loaded across 9 cycles, 607/607 sires and 472/472 dams linked, recursion verified to 9 generations, 188 pre-existing rows provably untouched.
- PETITIONER cross-check — 201,854 source pages give her foaling year as 1952
and 49 give it as
0. The load wrote 1952, taken from her own row: the design's central claim demonstrated on live data. - Decided:
foal_datestays and NULL is an accepted permanent state, not a gap to report against. (Robert, 23 August.) - Decided: reuse
rs.parsed_horses; do not re-scrape or re-parse. - Decided: horses with no country of origin remain excluded (rule R1), carrying forward the 16 August decision. Costs roughly 12 % of identities.
2026-08-20/21 — Schema v3 and tooling consolidation (project 0.3.0–0.3.2, commit e3ef37d)
breeding schema v3 (breaking)
- Redefined
breedingfrom a single canonicalCREATE TABLEapplied identically to both servers, rather than patching a table that had accumulated history. - Added
sire_year,sire_country_id,dam_year,dam_country_id. Storing onlysire_nameleft a later re-link nothing but a name string, and names repeat: GOLD DIGGER (ARG 1933) is not GOLD DIGGER2 (USA 1962); HOIST THE FLAG is 1968 USA (97,872 mentions) and 1970 NZ (1). Measured across 400,000 rows the parent's year and country are present inpayload_jsonfor 99.11 % of sires and 99.63 % of dams — and were being discarded at load. - Dropped
new_idandguid_id. The importer wrote 0 to everynew_idand never populatedguid_id; each carried an index over a single constant. - Column order made identical on both servers for the first time (
publish_namepreviously sat at position 19 locally and 3 on ODIN). Verified by signature13d52f55bd0fbb14e98e0912125c0f81. - 19 index parts reduced to 10 real indexes;
sire_name/dam_namenarrowed 60 → 45 to matchpublish_name(longest measured real value: 41). - Every column commented from
QDB_V8_1_Field_Map.md.
Tooling
check_breeding_candidates.py,load_breeding_candidates.pyandship_breeding_to_qdb.pymerged intobreeding_build.py— four commands (prepare,load,lock,ship) replacing 13 stages across three scripts.- Added
breeding_bundle.py, the incremental path: one horse plus two generations validated against whatbreedingalready holds, returning true/false/unknown per fact and refusing to write if any check failed. preparetakes horses by name —"Brutal (NZ) 2015"— resolving publish_name + country + year to akey_name. Ambiguous specs stop the run. Extended tobreeding_bundle.py --horseon 20 August, with an identical regex and country match so a horse name means the same thing whichever tool runs it.- Archived three scripts referencing the dropped
new_id.
Validation — 24 rules, 20 blocking
Age thresholds set by measurement over 400,000 rows: dam gap >20 warns / >30
blocks; sire gap >25 warns / >40 blocks. The >30 dam band is 321 rows of which
317 exceed 40 — the ~YYY- truncation artefact, max gap 2,005 years. Setting the
dam error at 20 would have blocked 5,011 real broodmare rows per 400,000. Sire
errors: 3,134 rows from only 508 distinct sires — a few hundred corrupt years
blocking thousands of horses each.
Fixed
- Unsigned underflow (crash).
year_of_foalissmallint unsigned, sos.year_of_foal - d.year_of_foalraised "BIGINT UNSIGNED value is out of range" whenever the dam was older than the sire — ordinary data.ABS()does not help. 10 subtractions across 8 rules nowCAST(... AS SIGNED). parent_cycle_3never finished on a large table. The rule joinedON c.key_name IN (b.sire_key_name, b.dam_key_name). AnINlist holding column expressions cannot resolve to an index lookup, so MySQL scanned the whole table per driving row — about 2.7 × 10¹¹ row reads. Rewritten as twoUNIONed equality joins: index scan →eq_ref→eq_ref, linear. Invisible at the 88- and 3,722-row scales it was written against; found when a 503,100-row run sat on it for over half an hour with no output. The other 23 rules wereEXPLAIN-checked at the same scale and are all linear.- All-runs scan. Staging a named page walked all 8 import runs (~15.2 M index entries each, ~122 M total) to find a page whose run was already known.
evidence_jsonwas silently dropped on ship. It was absent fromSHIP_COLUMNSon the assumption that abreeding_evidencetable existed; it never did.--stage checkpassed because it only tested that the column list was a subset of the source. The check now fails if any source column is not shipped.relinkmatched on name alone, defeating the point of the new columns. Now requires name + year + country + sex in two passes, and reports matches rejected on mismatch rather than skipping them silently.insertbecame a single set-basedINSERT ... SELECTwithJSON_OBJECT()built server-side.
Added — progress reporting
A silent console is indistinguishable from a hung one, which is exactly how the
parent_cycle_3 bug went unnoticed for half an hour. A progress() context
manager prints an elapsed-time heartbeat every 10 s from a daemon thread while a
statement is in flight; faster steps stay quiet. Applied to every tier step, all
24 validation rules, the insert, both relink passes, every verify check and each
build chunk.
Changed
lockbecame state-based, not tier-based: a row locks when it passed validation and both parents are named and resolved, regardless of tier. Tier records what was known at staging and never changes; completeness changes as data arrives. HALO was staged tier 2 with both parents NULL, and later loads brought HAIL TO REASON in — under a tier-1 rule HALO could become fully linked and still never lock.lockrefuses to run ahead oflock_breeding_from_studbook_profiles.py, which setsis_lockedandis_checkedand only considers unlocked rows. On the proving build, 53 of 73 unlocked rows would otherwise have been deniedis_checkedpermanently.
Caught before it shipped
Work on breeding_bundle.py --horse started against a stale cached copy of
the file (pre-schema-v3: still had new_id/guid_id). Diffing the patch against
the actual file on disk before writing anything caught it. Always re-stage and
diff before patching a file that may have moved on.
Proving runs
Built WINX, BRUTAL (NZ 2015) and KISS MOON (USA 2011) end to end: 88 identities,
45 sire links, 44 dam links, verify passing. Shipped an earlier 31-row version
to ODIN and verified an exact id-for-id copy (CRC32 match on both sides).
Demonstrated cross-load linking: HALO staged tier 2 with both parents NULL,
then connected to HAIL TO REASON by a later relink.
2026-08-15 — Investigation and repo housekeeping (project 0.2.1, commits f8be938, 5479bff)
No importer, parser or dashboard code shipped. Two identified fixes were designed and deliberately not applied pending Robert's go-ahead.
- Discovered the project had moved from
C:\Users\Robert\Codex\Projects\BreedingInfilltoC:\Users\Robert\Projects\BreedingInfill. The.gitfolder was empty and uninitialised;git initrun, remote added,.gitignorewritten, initial commit made (73 files). - Full live-state audit: 2,969,350
source_files; 88,092,047parsed_horses; 87,327,841auto_write/ 758,170review/ 6,036blockeddecisions; 1,014,732studbook_profiles; 8 completed import runs with contiguous coverage.rs.breedingandqdb.breedingboth confirmed at 0 rows. - Review-queue root cause. The dominant driver of 758,170
reviewrows wasunresolved_country_id, present on ~89 % of a 100k sample. Isolated to 8 country codes (DR, TRI, ROM, BUL, STV, STK, KSA, LEB) whoseracing_codeinrs.countryduplicatediso3instead of the real racing abbreviation. - Blocked-row triage, all 6,036 rows. A random sample of 400 of the 47,445
parse_errorfiles, reopened against the source site: 399 were PedigreeQuery's expected "missing horse" page, not parser failures. - Duplicate-identity shadow-blocking confirmed. A low-reference near-duplicate
identity (same name/country/sex, year off by ~1) can block real descendant data
attached to a heavily-referenced identity of the same name. The
BLACKLOCK MAREcluster is the single largest trueblocked_lineage_branchcascade in the dataset (7 descendants blocked). - Found the
~YYY-truncation bug at precision.BLACKLOCK MARE2'syear_of_foalis literally182. Run 1 alone has 17 rows withyear_of_foal < 1000, all consistent with an approximate-year-marker regex over-trimming by one character. A second instance (SIR WATKIN,~184-→ 184) confirmed it recurs. - Run 8 deep dive — 706 blocked rows, 637 age-related. Candidate matching produced 45 high-confidence, 91 medium, 117 ambiguous and 384 no-candidate matches. All 375 independently-verifiable candidates' own pages were checked against the source HTML. In every case the flagged "bad" year is exactly what PedigreeQuery's own page displays — a source-data problem, not an import or linking bug. No defensible auto-fix exists; the recommendation was to leave them blocked.
2026-07-14 — MySQL staging replaces SQLite (project 0.2.0)
Architecture
- Replaced SQLite as the active staging backend with local MySQL database
rs. SQLite scripts kept for historical reference only. - Added MySQL staging tables for runs, source files, parsed horses, decisions and
existing-breeding fixtures, plus a local
rs.breedingcopy, parent-link cleanup and Studbook locking stages. - Added branch-level blocking: descendants of an invalid sire/dam branch cannot auto-write.
- Continued ignoring generation 5, whose parent links are not reliably represented by the source page.
Studbook self-healing
- Indexed lookup against
rs.studbook_profiles. An exact unique horse name/year plus exact available parent names can fill missing horse country/year, extended to missing sire/dam country/year. - Existing non-null parsed values are never overwritten — the ancestor of the current merge rule.
- Parent year disagreements stored as warnings and evidence, not applied.
Performance
- Identified a leading-wildcard Studbook query (
LIKE '%name%') as the major slowdown; replaced with indexed exact-name lookup plus an indexed prefix fallback restricted to records with Australian context. Added bounded LRU Studbook caches (100,000 entries). - A 600-file benchmark improved from ~69 files/s to ~207–210 files/s.
- Four importer workers saturated the NVMe drive; settled on three, with equal non-overlapping 988,350-file ranges.
Dashboard and operations
- Compact worker progress with absolute source positions, deltas, rate, elapsed
and ETA; explicit
--worker-id; live aggregation of active workers; 10-second Runs page refresh; direct links to each worker's decisions, errors, reviews, blocked and auto-write rows. reset-mysql-local-rs --yestruncates staging andbreedingwhile always keepingcountryandstudbook_profiles.
Review logic
pre_1970,pre_1940and large parent-age gaps kept as warnings, not blocks.- Impossible parent ages, wrong parent sex, same sire/dam identity and blocked lineage branches kept as blocking.
2026-07-05 and earlier — SQLite prototype
The original tool imported PedigreeQuery HTML into a local staging SQLite
database at data/breeding_infill.sqlite3, with a review dashboard on
127.0.0.1:8765 serving the source HTML with PedigreeQuery's CSS rewritten to a
local copy. It was explicitly a staging and review tool, not a QDB writer. The
project then lived at C:\Users\Robert\Codex\Projects\BreedingInfill.
SQLite is no longer part of any supported path.