constructive_topology was ordered by memory address on the cpsat path

assign_solver="cpsat" gave a different leaf-type signature on every run
from an identical seed, in the same process. One line:

  assignable = scope if scope is not None else set(leaves)
  noncirc = [L for L in assignable if L not in circ]      # id() order

assignable is a set of dom.Node, and Node hashes by id() -- a memory
address -- so iterating it ordered noncirc, and hence room_slots, by where
the objects happened to land in memory. That shifts between calls within
one process as allocation patterns change, with no seed involved.

Only cpsat showed it. The greedy path re-sorts every slot list with -idx[L]
as a unique tiebreak and is immune to the incoming order; CP-SAT consumes
room_slots order as its model's variable order, and the labelling problem
has many equally-optimal solutions. Greedy was not more correct, it was
masking a defect that had been there all along.

Fix: iterate the tree-ordered list, use the set only for membership.

Verified on programme-house, harbor-house and maple-court: 1 distinct
signature over 5 runs on both solvers, and 1 across 4 processes started
with different PYTHONHASHSEED, so context_types' string sets are not a
second source. test_constructive_topology_is_bit_reproducible guards both.

Method: rather than guess which set was at fault, instrument
solve_room_labels with an id-free fingerprint of inputs and outputs and
isolate the FIRST call, since later calls legitimately depend on earlier
ones through leaf types. Five runs gave five distinct first-call inputs,
placing the fault upstream of the solver in one step.

Every A/B on the cpsat path was comparing arms that differed partly by
memory layout -- 39.5's cpsat-vs-greedy verdict included, already down for
re-measurement under homemaker-py-vjd. Same id()-keying hazard as the
documented geometry._cache issue and a plausible contributor to
homemaker-py-b8g, which stays open: n_workers>1 has its own BLAS mechanism
and is not addressed here.

Closes homemaker-py-fdp.

Lint at parity (46); tests 381 passed (2 new), 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
This commit is contained in:
Claude 2026-08-29 11:45:19 +00:00
parent ee050532d6
commit 148988df14
No known key found for this signature in database
4 changed files with 107 additions and 2 deletions

View file

@ -40,7 +40,7 @@
{"_type":"issue","id":"homemaker-py-9gj","title":"quality_uncrinkliness returns a flat hard 0.0, so the objective cannot rank two equally-buried layouts","description":"Narrowed remnant of homemaker-py-ssz after the owner's daylight ruling (DESIGN.md §38.11). A buried leaf usually IS a defect -- corridors and WCs included -- so scoring it badly is correct. The residual complaint is not that the value is low, it is that it is FLAT: quality_uncrinkliness returns exactly 0.0 for every zero-exposure leaf, and since evaluate_leaf multiplies factors into quality and process_storey accumulates value += quality * rate * area, two layouts that differ only in how badly buried their rooms are score identically.\n\nSo the objective gives the search no gradient to descend in precisely the region it most needs to escape. This is a search-mechanics problem, not a calibration one, and it should be judged on whether it helps the search escape -- NOT on fail counts, which by construction it will not move (the fails are real and should stay).\n\nNote the trap recorded in §38.9: an arm optimised under a modified objective must not be scored under the objective it modifies, and equally must not be scored under its own. For a pure gradient change that emits the same fail set, stock scoring IS valid -- that is the one case where the yardstick is sound.","acceptance_criteria":"A variant that keeps the fail set byte-identical to stock (every currently-failing leaf still fails) but is monotone in how buried a leaf is; A/B at fixed budget on harbor + maple with enough seeds to see past the one-seed variance that made the §38.8 n=3 result undecidable.","status":"open","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-28T23:14:04Z","created_by":"Claude","updated_at":"2026-08-28T23:14:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-9gj","title":"quality_uncrinkliness returns a flat hard 0.0, so the objective cannot rank two equally-buried layouts","description":"Narrowed remnant of homemaker-py-ssz after the owner's daylight ruling (DESIGN.md §38.11). A buried leaf usually IS a defect -- corridors and WCs included -- so scoring it badly is correct. The residual complaint is not that the value is low, it is that it is FLAT: quality_uncrinkliness returns exactly 0.0 for every zero-exposure leaf, and since evaluate_leaf multiplies factors into quality and process_storey accumulates value += quality * rate * area, two layouts that differ only in how badly buried their rooms are score identically.\n\nSo the objective gives the search no gradient to descend in precisely the region it most needs to escape. This is a search-mechanics problem, not a calibration one, and it should be judged on whether it helps the search escape -- NOT on fail counts, which by construction it will not move (the fails are real and should stay).\n\nNote the trap recorded in §38.9: an arm optimised under a modified objective must not be scored under the objective it modifies, and equally must not be scored under its own. For a pure gradient change that emits the same fail set, stock scoring IS valid -- that is the one case where the yardstick is sound.","acceptance_criteria":"A variant that keeps the fail set byte-identical to stock (every currently-failing leaf still fails) but is monotone in how buried a leaf is; A/B at fixed budget on harbor + maple with enough seeds to see past the one-seed variance that made the §38.8 n=3 result undecidable.","status":"open","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-28T23:14:04Z","created_by":"Claude","updated_at":"2026-08-28T23:14:04Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"homemaker-py-7b7","title":"health-centre demands 131% of its plot: the programme is unsatisfiable as written","description":"`evolve._preflight` (homemaker-py-tdp, DESIGN.md 39.11) reports health-centre demanding 240 m2 of floor per storey on a 183 m2 plot -- 131%. It is 1 storey (n_storeys_required=1, storey_minimum=1), so there is nowhere for the overflow to go.\n\nThe consequence is visible in every evaluated layout: 100% of rooms come out undersized, uniformly, at a median 0.60x their declared target. No search can fix this -- quality_size is being asked for something the plot cannot supply, so the size gaussian is pinned near-zero across the whole design and contributes no useful gradient. Contrast harbor-house and maple-court, where the seeder hits targets at median 1.01x.\n\nThis is a corpus data problem, not an engine problem. Either the plot is too small or the programme's sizes/counts were estimated too generously (the CLAUDE.md note that all corpus targets were 'estimated many years ago and never changed' applies).\n\nOptions, roughly in order of honesty:\n 1. enlarge the plot in examples/health-centre/init.dom to ~1.4x\n 2. scale down size/count in patterns.config until demand fits with headroom\n 3. raise the storey count so the demand spreads over 2 levels\n 4. leave it and treat health-centre as a deliberate over-tight brief, documented as such\n\nWhichever is chosen, note it in DESIGN.md 39.11 so the pre-flight warning is not read as a bug in the check.","acceptance_criteria":"health-centre's demanded area per storey fits its plot with headroom, OR the programme is explicitly documented as a deliberately over-tight brief; `evolve._preflight` no longer reports it as an unexplained anomaly; median room area / (target * share) on a constructed seed is materially above 0.60x.","status":"closed","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-26T16:20:31Z","created_by":"Claude","updated_at":"2026-08-29T10:20:00Z","closed_at":"2026-08-29T10:20:00Z","close_reason":"Fixed per the owner's ruling: enlarge the plot, sized assuming a courtyard\n(DESIGN.md §38.13).\n\nThe plot is a quadrilateral in this engine, so the courtyard is interior space\nthe search carves out, not a hole in the site. Sizing therefore has to allow for\nthree things, only one of which the programme declares: rooms 240 m2, circulation\n~65 m2 (~27%, NOT in demand -- the search creates C leaves), courtyard ~36 m2.\n\nScaled about the polygon centroid by k = 1.4606, chosen so the INSET area (what\nleaves actually get, after wall_outer) lands on 400 m2. Centroid scaling keeps\nthe site's irregular shape and its one private edge -- same site, larger.\n\n plot 183.2 -\u003e 400.0 m2\n rooms / plot 131% -\u003e 60% (harbor's ratio exactly, and harbor works)\n daylit perim 41.5 -\u003e 61.3 m (49.4 needed)\n median room area / (target x share) 0.60x -\u003e 1.00x\n\nThat last figure is the one that matters: 0.60x with 100% of rooms undersized\nwas the geometric signature of the infeasibility, and it is now level with\nharbor/maple at 1.01x.\n\nThe courtyard remains REQUIRED by the geometry rather than merely permitted: a\nroom is at most 1.6202*h = 4.86 m deep before failing crinkliness, so a ~20x20 m\nplot with a daylit ring leaves a 10.3 x 10.3 m, 106 m2 core that cannot reach an\nexternal wall. Enlarging did not remove the pressure that produces a courtyard,\nit made room for one.\n\nevolve._preflight is now silent on health-centre. Unblocks homemaker-py-7xb.\n","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"homemaker-py-7b7","title":"health-centre demands 131% of its plot: the programme is unsatisfiable as written","description":"`evolve._preflight` (homemaker-py-tdp, DESIGN.md 39.11) reports health-centre demanding 240 m2 of floor per storey on a 183 m2 plot -- 131%. It is 1 storey (n_storeys_required=1, storey_minimum=1), so there is nowhere for the overflow to go.\n\nThe consequence is visible in every evaluated layout: 100% of rooms come out undersized, uniformly, at a median 0.60x their declared target. No search can fix this -- quality_size is being asked for something the plot cannot supply, so the size gaussian is pinned near-zero across the whole design and contributes no useful gradient. Contrast harbor-house and maple-court, where the seeder hits targets at median 1.01x.\n\nThis is a corpus data problem, not an engine problem. Either the plot is too small or the programme's sizes/counts were estimated too generously (the CLAUDE.md note that all corpus targets were 'estimated many years ago and never changed' applies).\n\nOptions, roughly in order of honesty:\n 1. enlarge the plot in examples/health-centre/init.dom to ~1.4x\n 2. scale down size/count in patterns.config until demand fits with headroom\n 3. raise the storey count so the demand spreads over 2 levels\n 4. leave it and treat health-centre as a deliberate over-tight brief, documented as such\n\nWhichever is chosen, note it in DESIGN.md 39.11 so the pre-flight warning is not read as a bug in the check.","acceptance_criteria":"health-centre's demanded area per storey fits its plot with headroom, OR the programme is explicitly documented as a deliberately over-tight brief; `evolve._preflight` no longer reports it as an unexplained anomaly; median room area / (target * share) on a constructed seed is materially above 0.60x.","status":"closed","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-26T16:20:31Z","created_by":"Claude","updated_at":"2026-08-29T10:20:00Z","closed_at":"2026-08-29T10:20:00Z","close_reason":"Fixed per the owner's ruling: enlarge the plot, sized assuming a courtyard\n(DESIGN.md §38.13).\n\nThe plot is a quadrilateral in this engine, so the courtyard is interior space\nthe search carves out, not a hole in the site. Sizing therefore has to allow for\nthree things, only one of which the programme declares: rooms 240 m2, circulation\n~65 m2 (~27%, NOT in demand -- the search creates C leaves), courtyard ~36 m2.\n\nScaled about the polygon centroid by k = 1.4606, chosen so the INSET area (what\nleaves actually get, after wall_outer) lands on 400 m2. Centroid scaling keeps\nthe site's irregular shape and its one private edge -- same site, larger.\n\n plot 183.2 -\u003e 400.0 m2\n rooms / plot 131% -\u003e 60% (harbor's ratio exactly, and harbor works)\n daylit perim 41.5 -\u003e 61.3 m (49.4 needed)\n median room area / (target x share) 0.60x -\u003e 1.00x\n\nThat last figure is the one that matters: 0.60x with 100% of rooms undersized\nwas the geometric signature of the infeasibility, and it is now level with\nharbor/maple at 1.01x.\n\nThe courtyard remains REQUIRED by the geometry rather than merely permitted: a\nroom is at most 1.6202*h = 4.86 m deep before failing crinkliness, so a ~20x20 m\nplot with a daylit ring leaves a 10.3 x 10.3 m, 106 m2 core that cannot reach an\nexternal wall. Enlarging did not remove the pressure that produces a courtyard,\nit made room for one.\n\nevolve._preflight is now silent on health-centre. Unblocks homemaker-py-7xb.\n","dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"homemaker-py-3qj","title":"Programmes do not declare bedroom/treatment-room to toilet adjacency, so the Brand adaptability credit is never earned","description":"Established while settling homemaker-py-sel. A toilet adjacent to a sleeping or treatment room is a positive even with no door between them -- Stewart Brand, How Buildings Learn: the building may need it knocked through later, and the adjacency is what makes that possible.\\n\\nThe engine already supports this. fitness._evaluate_full runs graph.check_adjacency against graph_base_pre, the UNFILTERED adjacency graph, so a declared adjacency requirement is satisfied by a neighbouring room regardless of the edges graph.has_circulation later strips for routing. (The stripping is correct on its own terms -- you cannot walk through a bedroom to reach the loo.)\\n\\nBut it is only ever scored where a programme DECLARES it, and only one corpus programme does:\\n programme-house (+ derivatives) t1 -\u003e adjacency: [b1] DECLARED\\n harbor-house t (Bathroom, count 6) vs r / n (sleeping) not declared\\n maple-court t, tt vs r / n not declared\\n health-centre t9 / t10 vs tr1 (Treatment Room) not declared\\n\\nSo three of the four real programmes leave the adaptability credit on the table: the search has no reason to place a WC next to a dormitory or a treatment room, because nothing scores it.\\n\\nThis is programme authoring, not an engine change, and it is independent of the usage-key work -- it can land before or after.","acceptance_criteria":"harbor-house, maple-court and health-centre declare the sleeping/treatment-to-toilet adjacencies their briefs imply; re-baselined so the change in fail counts is attributed rather than absorbed.","status":"closed","priority":2,"issue_type":"task","owner":"noreply@anthropic.com","created_at":"2026-08-26T12:33:39Z","created_by":"Claude","updated_at":"2026-08-29T10:57:25Z","closed_at":"2026-08-29T10:57:25Z","close_reason":"Declared where the brief supports it (DESIGN.md §38.14).\n\n harbor-house t -\u003e n Bathrooms serve the Neighborhoods (communal sleeping).\n Both unpinned, 6 t / 5 n.\n maple-court tt -\u003e r Upper Bathrooms among Individual Rooms, both level 2,\n already 62% adjacent at seed time.\n\nNOT declared, and checking before declaring is what caught these:\n\n maple t -\u003e n is IMPOSSIBLE. Adjacency is evaluated per level (graph_base[li]),\n and maple pins t to level 0, n to level 1. Declaring it would have added six\n permanently unsatisfiable fails. The 0% seed-time rate I measured was a hard\n impossibility, not search difficulty. maple's ground floor has six bathrooms\n and exactly one sleeping room (Clinic Room x1) -- a ground-floor WC in a\n communal building is public, so Brand does not apply to it anyway.\n\n health-centre has no dedicated WC. The owner's ruling on treatment rooms was\n that one \"may give access to a toilet, but this would be a DEDICATED toilet\".\n t9 is a Public WC (x2), t10 a Staff WC; making either dedicated-adjacent to\n tr1 contradicts that. Earning the credit here needs a dedicated WC added to\n the brief -- programme authoring beyond this issue. Filed separately.\n\nBoth declarations are reachable (best of 8 seeds: 2/3 harbor, 2/2 maple), so the\nsearch gets a gradient not a permanent penalty. evolved-3M-nols-3: 84 -\u003e 89\nfails, all five the new requirement.\n\nCost: cpsat assignment got ~7.5x slower on harbor (0.28 -\u003e 2.11s per seed);\ngreedy, the default, is unchanged at 0.06s. Filed as a follow-up since it bears\non §39.5's cpsat-vs-greedy verdict.\n\nTwo tests were over-fitted to the old seeds and were repaired to assert their\nintent rather than relaxed: reassign now sweeps six constructive seeds (seed 0's\nbetter-seeded design legitimately has nothing to improve; 5 of 6 others fire),\nand repair_circulation asserts that repair strictly helps plus a \u003e=85% bar\ninstead of a sampled 100% (measured 25% -\u003e 92%, stable over 6 and 12 seeds).\n","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-3qj","title":"Programmes do not declare bedroom/treatment-room to toilet adjacency, so the Brand adaptability credit is never earned","description":"Established while settling homemaker-py-sel. A toilet adjacent to a sleeping or treatment room is a positive even with no door between them -- Stewart Brand, How Buildings Learn: the building may need it knocked through later, and the adjacency is what makes that possible.\\n\\nThe engine already supports this. fitness._evaluate_full runs graph.check_adjacency against graph_base_pre, the UNFILTERED adjacency graph, so a declared adjacency requirement is satisfied by a neighbouring room regardless of the edges graph.has_circulation later strips for routing. (The stripping is correct on its own terms -- you cannot walk through a bedroom to reach the loo.)\\n\\nBut it is only ever scored where a programme DECLARES it, and only one corpus programme does:\\n programme-house (+ derivatives) t1 -\u003e adjacency: [b1] DECLARED\\n harbor-house t (Bathroom, count 6) vs r / n (sleeping) not declared\\n maple-court t, tt vs r / n not declared\\n health-centre t9 / t10 vs tr1 (Treatment Room) not declared\\n\\nSo three of the four real programmes leave the adaptability credit on the table: the search has no reason to place a WC next to a dormitory or a treatment room, because nothing scores it.\\n\\nThis is programme authoring, not an engine change, and it is independent of the usage-key work -- it can land before or after.","acceptance_criteria":"harbor-house, maple-court and health-centre declare the sleeping/treatment-to-toilet adjacencies their briefs imply; re-baselined so the change in fail counts is attributed rather than absorbed.","status":"closed","priority":2,"issue_type":"task","owner":"noreply@anthropic.com","created_at":"2026-08-26T12:33:39Z","created_by":"Claude","updated_at":"2026-08-29T10:57:25Z","closed_at":"2026-08-29T10:57:25Z","close_reason":"Declared where the brief supports it (DESIGN.md §38.14).\n\n harbor-house t -\u003e n Bathrooms serve the Neighborhoods (communal sleeping).\n Both unpinned, 6 t / 5 n.\n maple-court tt -\u003e r Upper Bathrooms among Individual Rooms, both level 2,\n already 62% adjacent at seed time.\n\nNOT declared, and checking before declaring is what caught these:\n\n maple t -\u003e n is IMPOSSIBLE. Adjacency is evaluated per level (graph_base[li]),\n and maple pins t to level 0, n to level 1. Declaring it would have added six\n permanently unsatisfiable fails. The 0% seed-time rate I measured was a hard\n impossibility, not search difficulty. maple's ground floor has six bathrooms\n and exactly one sleeping room (Clinic Room x1) -- a ground-floor WC in a\n communal building is public, so Brand does not apply to it anyway.\n\n health-centre has no dedicated WC. The owner's ruling on treatment rooms was\n that one \"may give access to a toilet, but this would be a DEDICATED toilet\".\n t9 is a Public WC (x2), t10 a Staff WC; making either dedicated-adjacent to\n tr1 contradicts that. Earning the credit here needs a dedicated WC added to\n the brief -- programme authoring beyond this issue. Filed separately.\n\nBoth declarations are reachable (best of 8 seeds: 2/3 harbor, 2/2 maple), so the\nsearch gets a gradient not a permanent penalty. evolved-3M-nols-3: 84 -\u003e 89\nfails, all five the new requirement.\n\nCost: cpsat assignment got ~7.5x slower on harbor (0.28 -\u003e 2.11s per seed);\ngreedy, the default, is unchanged at 0.06s. Filed as a follow-up since it bears\non §39.5's cpsat-vs-greedy verdict.\n\nTwo tests were over-fitted to the old seeds and were repaired to assert their\nintent rather than relaxed: reassign now sweeps six constructive seeds (seed 0's\nbetter-seeded design legitimately has nothing to improve; 5 of 6 others fire),\nand repair_circulation asserts that repair strictly helps plus a \u003e=85% bar\ninstead of a sampled 100% (measured 25% -\u003e 92%, stable over 6 and 12 seeds).\n","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"homemaker-py-fdp","title":"constructive_topology is not bit-reproducible on the assign_solver=cpsat path","description":"Established while fixing §39.5. Narrowed but not closed:\\n\\n- cpsat.solve_room_labels IS now reproducible on every captured instance, after two fixes: sorting the model-build order (neighbors[slot] is a set of dom.Node, which hashes by id() -- a memory address -- so raw iteration made the model order vary and CP-SAT returned a different equally-optimal assignment each run), and adding max_deterministic_time alongside the wall-clock cap.\\n- operators.constructive_topology(assign_solver='greedy') IS reproducible.\\n- operators.constructive_topology(assign_solver='cpsat') is NOT: identical seed in the same process gives different leaf-type signatures across runs.\\n\\nSo something upstream of the solver in _assign_adjacency_aware still varies on the cpsat branch. Disabling _cpsat_relabel_settled does not fix it, so it is the first call site. Suspect another id()-hashed set of Nodes feeding slot/neighbour ordering.\\n\\nPlausible contributor to homemaker-py-b8g (parallel/BLAS non-determinism in n_workers\u003e1 runs) -- same id-keying hazard class as the documented geometry._cache issue. Meanwhile tests/test_operators.py::test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency averages three repeats instead of asserting on one run.","acceptance_criteria":"constructive_topology(assign_solver='cpsat') produces identical output for identical seeds in-process and across processes; the A/B test can go back to a single-run assertion.","status":"open","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-26T10:06:55Z","created_by":"Claude","updated_at":"2026-08-26T10:06:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-fdp","title":"constructive_topology is not bit-reproducible on the assign_solver=cpsat path","description":"Established while fixing §39.5. Narrowed but not closed:\\n\\n- cpsat.solve_room_labels IS now reproducible on every captured instance, after two fixes: sorting the model-build order (neighbors[slot] is a set of dom.Node, which hashes by id() -- a memory address -- so raw iteration made the model order vary and CP-SAT returned a different equally-optimal assignment each run), and adding max_deterministic_time alongside the wall-clock cap.\\n- operators.constructive_topology(assign_solver='greedy') IS reproducible.\\n- operators.constructive_topology(assign_solver='cpsat') is NOT: identical seed in the same process gives different leaf-type signatures across runs.\\n\\nSo something upstream of the solver in _assign_adjacency_aware still varies on the cpsat branch. Disabling _cpsat_relabel_settled does not fix it, so it is the first call site. Suspect another id()-hashed set of Nodes feeding slot/neighbour ordering.\\n\\nPlausible contributor to homemaker-py-b8g (parallel/BLAS non-determinism in n_workers\u003e1 runs) -- same id-keying hazard class as the documented geometry._cache issue. Meanwhile tests/test_operators.py::test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency averages three repeats instead of asserting on one run.","acceptance_criteria":"constructive_topology(assign_solver='cpsat') produces identical output for identical seeds in-process and across processes; the A/B test can go back to a single-run assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-08-26T10:06:55Z","created_by":"Claude","updated_at":"2026-08-29T11:45:19Z","started_at":"2026-08-29T11:35:49Z","closed_at":"2026-08-29T11:45:19Z","close_reason":"Fixed. One line (DESIGN.md §38.15):\n\n assignable = scope if scope is not None else set(leaves)\n noncirc = [L for L in assignable if L not in circ] # \u003c-- id() order\n\nassignable is a set of dom.Node, which hashes by id() -- a memory address -- so\niterating it ordered noncirc, and hence room_slots, by where the objects landed\nin memory. That varies between calls within one process, no seed involved.\n\nOnly cpsat showed it because the greedy path re-sorts every slot list with\n-idx[L] as a unique tiebreak and is immune to the incoming order; CP-SAT\nconsumes room_slots order as its model's variable order, and the labelling\nproblem has many equally-optimal solutions. Greedy was not more correct, it was\nmasking a defect that had been there all along.\n\nFix: noncirc = [L for L in leaves if L in assignable and L not in circ] --\niterate the tree-ordered list, use the set only for membership.\n\nVerified on programme-house, harbor-house and maple-court: 1 distinct signature\nover 5 runs on BOTH solvers, and 1 distinct signature across 4 processes with\ndifferent PYTHONHASHSEED (so context_types' string sets are not a second\nsource). That is stronger than the acceptance criteria asked for.\n\nMethod worth reusing: rather than guessing which set was at fault, I\ninstrumented solve_room_labels with an id-free fingerprint of inputs and\noutputs and isolated the FIRST call (later calls legitimately depend on earlier\nones via leaf types). Five runs, five distinct first-call inputs -\u003e fault is\nupstream of the solver, located in one step.\n\ntest_constructive_topology_is_bit_reproducible guards both solvers.\n\nBearing on other issues: every A/B on the cpsat path was comparing arms that\ndiffered partly by memory layout, §39.5's cpsat-vs-greedy verdict included\n(already down for re-measurement under homemaker-py-vjd). Same id()-keying\nhazard class as the geometry._cache issue and a plausible contributor to\nhomemaker-py-b8g, which stays open -- n_workers\u003e1 has its own BLAS mechanism and\nis not addressed here.\n","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"homemaker-py-0wr","title":"Re-check any harbor-house A/B decided by a small margin before the §39.4 namespace fix","description":"Until §39.4, harbor-house scored against a 32-instance EFFECTIVE programme -- cr1/of/st1/st2 (14% of room instances) were dropped by the count check and mis-parameterised by the generic c/o/s prefix rule. Any harbor A/B decided by a narrow margin was therefore decided against a programme missing a seventh of its rooms.\\n\\nOne confirmed casualty already: 2g7.5's CP-SAT seeder win (§37.7). Measured over 6 seeds on the corrected programme, greedy 102 / cpsat 114 -- cpsat now LOSES; on the old 32-instance programme it was 98/99, a tie, so the recorded win was marginal from the start. Control: on namespace-clean maple-court cpsat still wins 144/156, so the solver did not regress. Test marked xfail with a companion maple-court assertion; both assign_solver flags stay default off.\\n\\nWorth re-checking with the same lens: §13.9/§13.11 floors, §17/§20 collapse A/Bs, §23 ruin-recreate, §29/§30 beam width, §37.1 tiering -- anything whose harbor arm was close.","acceptance_criteria":"Each narrow-margin harbor result either re-measured on the corrected programme or annotated in DESIGN.md as pre-§39.4.","status":"open","priority":2,"issue_type":"task","owner":"noreply@anthropic.com","created_at":"2026-08-26T09:44:55Z","created_by":"Claude","updated_at":"2026-08-26T09:44:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-0wr","title":"Re-check any harbor-house A/B decided by a small margin before the §39.4 namespace fix","description":"Until §39.4, harbor-house scored against a 32-instance EFFECTIVE programme -- cr1/of/st1/st2 (14% of room instances) were dropped by the count check and mis-parameterised by the generic c/o/s prefix rule. Any harbor A/B decided by a narrow margin was therefore decided against a programme missing a seventh of its rooms.\\n\\nOne confirmed casualty already: 2g7.5's CP-SAT seeder win (§37.7). Measured over 6 seeds on the corrected programme, greedy 102 / cpsat 114 -- cpsat now LOSES; on the old 32-instance programme it was 98/99, a tie, so the recorded win was marginal from the start. Control: on namespace-clean maple-court cpsat still wins 144/156, so the solver did not regress. Test marked xfail with a companion maple-court assertion; both assign_solver flags stay default off.\\n\\nWorth re-checking with the same lens: §13.9/§13.11 floors, §17/§20 collapse A/Bs, §23 ruin-recreate, §29/§30 beam width, §37.1 tiering -- anything whose harbor arm was close.","acceptance_criteria":"Each narrow-margin harbor result either re-measured on the corrected programme or annotated in DESIGN.md as pre-§39.4.","status":"open","priority":2,"issue_type":"task","owner":"noreply@anthropic.com","created_at":"2026-08-26T09:44:55Z","created_by":"Claude","updated_at":"2026-08-26T09:44:55Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"homemaker-py-1i8","title":"Missing-space fail cascade weights rooms by patterns.config verbosity, not by design intent","description":"graph.check_space_counts emits, per missing room instance: 2 base fails ('missing required space: X' + '(critical)') plus one 'would need \u003ccheck\u003e' placeholder for each of size/width/proportion the programme HAPPENS to declare (has_size/has_width/has_proportion are literally 'size' in c etc. from the YAML). So a missing room costs 3 to 5 fails depending only on how many optional keys the author typed. Under value *= 0.5**len(failures) that is a 4x difference in fitness weight between two single rooms. Concretely in programme-house: missing b1 (declares size+width+proportion) = 5 fails = 1/32 penalty; missing t2 (declares size only) = 3 fails = 1/8 penalty. Same for harbor-house: n and cr1 cost 5 each, r and t cost 4 each. The tiered comparator inherits this -- n_hard is dominated by these cascades, so the primary search key is weighted by YAML verbosity.","design":"Either emit exactly one fail per missing instance (and let the cascade placeholders be informational, not counted), or normalise the cascade to a fixed count per instance independent of declared keys. Note this changes every historical fail-count baseline in DESIGN.md, so it needs its own A/B and a recorded re-baseline.","status":"closed","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:15:00Z","created_by":"Claude","updated_at":"2026-08-29T09:33:24Z","closed_at":"2026-08-29T09:33:24Z","close_reason":"Fixed by emitting all three quality placeholders unconditionally (DESIGN.md\n§38.12). A fixed 5 fails per missing instance, independent of how the programme\nwas spelled.\n\nThe decisive evidence is that the two paths disagreed about the same room. A\nPRESENT room is checked on all three qualities regardless of declaration --\nget_space_params fills width and proportion from defaults, deriving width from\nsize when absent, so programme-house's t2 declares `size:` alone and still gets\na real width target of 1.633 it can fail on. Missing, it emitted one placeholder\nwhere b1 emitted three. The cascade stands in for the checks that could not run,\nand it was standing in for the wrong number of them.\n\nCorpus: 36 of 67 codes were under-counted, 31 already at 5. Max weight ratio\nbetween two single rooms goes 4x -\u003e 1x (programme-house), 2x -\u003e 1x (harbor,\nmaple), 1x -\u003e 1x (health-centre, already uniform).\n\nThis makes fail counts LARGER, which is correct and not a regression:\n harbor evolved-3M-nols-3 82/37h -\u003e 84/39h\n harbor generated 155/128h -\u003e 174/147h\n harbor evolved-3M 131/87h -\u003e 144/100h\n maple generated 126/17h -\u003e unchanged (no missing instances)\n\nNOT taken: the \"one fail per missing instance, placeholders informational\"\noption. It fixes the verbosity dependence too, but silently rescales a missing\nroom from 1/32 to 1/2 -- the same weight as one crinkliness fail. Whether a\nmissing required room SHOULD cost 1/32 is a real question and a separate one;\nbundling it here would have changed the objective's priorities under cover of a\nbug fix. Magnitude left exactly where it was. Filed as a follow-up.\n","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-1i8","title":"Missing-space fail cascade weights rooms by patterns.config verbosity, not by design intent","description":"graph.check_space_counts emits, per missing room instance: 2 base fails ('missing required space: X' + '(critical)') plus one 'would need \u003ccheck\u003e' placeholder for each of size/width/proportion the programme HAPPENS to declare (has_size/has_width/has_proportion are literally 'size' in c etc. from the YAML). So a missing room costs 3 to 5 fails depending only on how many optional keys the author typed. Under value *= 0.5**len(failures) that is a 4x difference in fitness weight between two single rooms. Concretely in programme-house: missing b1 (declares size+width+proportion) = 5 fails = 1/32 penalty; missing t2 (declares size only) = 3 fails = 1/8 penalty. Same for harbor-house: n and cr1 cost 5 each, r and t cost 4 each. The tiered comparator inherits this -- n_hard is dominated by these cascades, so the primary search key is weighted by YAML verbosity.","design":"Either emit exactly one fail per missing instance (and let the cascade placeholders be informational, not counted), or normalise the cascade to a fixed count per instance independent of declared keys. Note this changes every historical fail-count baseline in DESIGN.md, so it needs its own A/B and a recorded re-baseline.","status":"closed","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:15:00Z","created_by":"Claude","updated_at":"2026-08-29T09:33:24Z","closed_at":"2026-08-29T09:33:24Z","close_reason":"Fixed by emitting all three quality placeholders unconditionally (DESIGN.md\n§38.12). A fixed 5 fails per missing instance, independent of how the programme\nwas spelled.\n\nThe decisive evidence is that the two paths disagreed about the same room. A\nPRESENT room is checked on all three qualities regardless of declaration --\nget_space_params fills width and proportion from defaults, deriving width from\nsize when absent, so programme-house's t2 declares `size:` alone and still gets\na real width target of 1.633 it can fail on. Missing, it emitted one placeholder\nwhere b1 emitted three. The cascade stands in for the checks that could not run,\nand it was standing in for the wrong number of them.\n\nCorpus: 36 of 67 codes were under-counted, 31 already at 5. Max weight ratio\nbetween two single rooms goes 4x -\u003e 1x (programme-house), 2x -\u003e 1x (harbor,\nmaple), 1x -\u003e 1x (health-centre, already uniform).\n\nThis makes fail counts LARGER, which is correct and not a regression:\n harbor evolved-3M-nols-3 82/37h -\u003e 84/39h\n harbor generated 155/128h -\u003e 174/147h\n harbor evolved-3M 131/87h -\u003e 144/100h\n maple generated 126/17h -\u003e unchanged (no missing instances)\n\nNOT taken: the \"one fail per missing instance, placeholders informational\"\noption. It fixes the verbosity dependence too, but silently rescales a missing\nroom from 1/32 to 1/2 -- the same weight as one crinkliness fail. Whether a\nmissing required room SHOULD cost 1/32 is a real question and a separate one;\nbundling it here would have changed the objective's priorities under cover of a\nbug fix. Magnitude left exactly where it was. Filed as a follow-up.\n","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"homemaker-py-gvb","title":"Crinkliness is mis-tiered as SOFT, but most crinkliness fails are topological (zero-exposure) and unreachable by the inner loop","description":"fitness._SOFT_FAIL_MARKERS lists ' crinkliness' as SOFT, defined in 37.1 as 'a continuous per-leaf shape metric the inner-loop ratio solve can improve without changing the tree'. That is false for the zero-exposure case: a leaf with no daylit wall cannot be given one by ANY ratio assignment -- it needs a topology change, which is the document's own definition of HARD. Measured share of crinkliness fails that are zero-exposure: harbor-house 60% (36 of 60), maple-court 65% (83 of 127), health-centre 100% (35 of 35); on a converged 20k harbor run, 14 of 17 (82%). Since crinkliness is the single largest fail category (48% of the residual per 13.11), the tiered comparator from 2g7.3 is mis-informed about the largest block of fails it sorts: it tells the search 'these ~40 soft fails are polishable' when two-thirds of them are structurally unreachable, and n_soft is therefore not the polish-budget signal it was designed to be.","design":"Split the crinkliness fail into two strings (or tier it dynamically on area_outside==0) so zero-exposure counts HARD and wrong-ratio counts SOFT. classify_fail_tier is string-based, so the cleanest fix is emitting a distinct fail string for the zero-exposure case -- which also makes the condition visible in .fails output, where today it is indistinguishable from an ordinary shape miss.","acceptance_criteria":"Distinct fail string for zero-exposure; classify_fail_tier maps it HARD; re-run the 37.1 tiered-vs-flat A/B, whose hard/soft split changes materially under the corrected tiering.","status":"open","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:15:00Z","created_by":"Claude","updated_at":"2026-08-25T23:15:00Z","dependencies":[{"issue_id":"homemaker-py-gvb","depends_on_id":"homemaker-py-ssz","type":"blocks","created_at":"2026-08-25T23:15:13Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-gvb","title":"Crinkliness is mis-tiered as SOFT, but most crinkliness fails are topological (zero-exposure) and unreachable by the inner loop","description":"fitness._SOFT_FAIL_MARKERS lists ' crinkliness' as SOFT, defined in 37.1 as 'a continuous per-leaf shape metric the inner-loop ratio solve can improve without changing the tree'. That is false for the zero-exposure case: a leaf with no daylit wall cannot be given one by ANY ratio assignment -- it needs a topology change, which is the document's own definition of HARD. Measured share of crinkliness fails that are zero-exposure: harbor-house 60% (36 of 60), maple-court 65% (83 of 127), health-centre 100% (35 of 35); on a converged 20k harbor run, 14 of 17 (82%). Since crinkliness is the single largest fail category (48% of the residual per 13.11), the tiered comparator from 2g7.3 is mis-informed about the largest block of fails it sorts: it tells the search 'these ~40 soft fails are polishable' when two-thirds of them are structurally unreachable, and n_soft is therefore not the polish-budget signal it was designed to be.","design":"Split the crinkliness fail into two strings (or tier it dynamically on area_outside==0) so zero-exposure counts HARD and wrong-ratio counts SOFT. classify_fail_tier is string-based, so the cleanest fix is emitting a distinct fail string for the zero-exposure case -- which also makes the condition visible in .fails output, where today it is indistinguishable from an ordinary shape miss.","acceptance_criteria":"Distinct fail string for zero-exposure; classify_fail_tier maps it HARD; re-run the 37.1 tiered-vs-flat A/B, whose hard/soft split changes materially under the corrected tiering.","status":"open","priority":2,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:15:00Z","created_by":"Claude","updated_at":"2026-08-25T23:15:00Z","dependencies":[{"issue_id":"homemaker-py-gvb","depends_on_id":"homemaker-py-ssz","type":"blocks","created_at":"2026-08-25T23:15:13Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}

View file

@ -5610,6 +5610,62 @@ pass:
25% → 92%, stable across 6 and 12 seeds. It now asserts the real claim (repair 25% → 92%, stable across 6 and 12 seeds. It now asserts the real claim (repair
strictly helps) plus a ≥85% regression bar. strictly helps) plus a ≥85% regression bar.
### 38.15 `constructive_topology` was ordered by memory address (`homemaker-py-fdp`)
`assign_solver="cpsat"` gave a different leaf-type signature on every run from
an identical seed, **in the same process**. Narrowed to one line:
```python
assignable = scope if scope is not None else set(leaves)
...
noncirc = [L for L in assignable if L not in circ] # <-- id() order
room_slots = [L for L in noncirc if L not in o_set]
```
`assignable` is a `set` of `dom.Node`, and `Node` hashes by `id()` — a memory
address. Iterating it ordered `noncirc`, and therefore `room_slots`, by where
the objects happened to land in memory. That changes between calls within one
process as allocation patterns shift, with no seed involved at all.
**Why only cpsat showed it.** The greedy path re-sorts every slot list with
`-idx[L]` as a unique tiebreak, so it is immune to whatever order arrives.
CP-SAT consumes `room_slots` order as its model's variable order, and since the
labelling problem has many equally-optimal solutions, a different variable order
returns a different one. Greedy was not more correct — it was masking a defect
that had been there all along.
**Method note.** Guessing at candidate `set`s would have been slow and
unreliable — there are several, and most are harmless because they feed a `max()`
with a unique tiebreak. What settled it was instrumenting `solve_room_labels`
with an id-free fingerprint of its inputs and outputs, then isolating the
*first* call (later calls legitimately depend on earlier ones through leaf
types). Five runs gave five distinct inputs to the first call, which located the
fault upstream of the solver in one step.
**Fix:** iterate the tree-ordered list, use the set only for membership.
```python
noncirc = [L for L in leaves if L in assignable and L not in circ]
```
**Verified** on programme-house, harbor-house and maple-court: one distinct
signature over 5 runs on both solvers, and — a stronger result than the issue
asked for — one distinct signature across 4 processes started with different
`PYTHONHASHSEED`, so the string-keyed `context_types` sets are not a second
source.
`test_constructive_topology_is_bit_reproducible` guards both solvers. Repetition
*in one process* is what catches this class: allocation order changes without
any seed changing.
**Why this mattered beyond cpsat.** Every A/B in this document rests on being
able to re-run a configuration and get the same answer. A/Bs on the cpsat path
were comparing arms that differed partly by memory layout — §39.5's
cpsat-versus-greedy verdict among them, which is already down for re-measurement
under `homemaker-py-vjd`. This is the same `id()`-keying hazard as the
documented `geometry._cache` issue and a plausible contributor to
`homemaker-py-b8g`.
## 39. Config audit: requirements that actively fight the engine (`homemaker-py-ju3`) — measured 2026-08-25 ## 39. Config audit: requirements that actively fight the engine (`homemaker-py-ju3`) — measured 2026-08-25
The corpus `patterns.config` targets and `costs.config` values were estimated The corpus `patterns.config` targets and `costs.config` values were estimated

View file

@ -1209,7 +1209,17 @@ def _assign_adjacency_aware(lvl: dom.Node, room_codes: list[str], reqs,
if s in assignable: # never retype a fixed_circ seed outside scope if s in assignable: # never retype a fixed_circ seed outside scope
s.type = "C" s.type = "C"
noncirc = [L for L in assignable if L not in circ] # homemaker-py-fdp: iterate `leaves` (tree order) and use `assignable` only
# for MEMBERSHIP. `assignable` is a set of dom.Node, which hashes by id() --
# a memory address -- so iterating it directly ordered `noncirc`, and hence
# `room_slots`, by where the objects happened to land in memory. That varies
# between runs in one process, so `assign_solver="cpsat"` returned a
# different (equally optimal) labelling every time.
#
# The greedy path never noticed: it re-sorts every slot list with `-idx[L]`
# as a unique tiebreak, which is immune to the incoming order. cpsat consumes
# `room_slots` order as the model's variable order, so it exposed it.
noncirc = [L for L in leaves if L in assignable and L not in circ]
if interior_outside: if interior_outside:
# ld2 (§13.6): seed ``O`` as INTERIOR light wells instead of one # ld2 (§13.6): seed ``O`` as INTERIOR light wells instead of one
# peripheral leaf. A landlocked room (no plot facade, no uncovered-O # peripheral leaf. A landlocked room (no plot facade, no uncovered-O

View file

@ -1044,3 +1044,42 @@ def test_preserve_circulation_default_off_reproduces_prior_seeds():
assert sig() == sig(preserve_circulation=False) assert sig() == sig(preserve_circulation=False)
# ...and it does change something when enabled, or the A/B measured nothing # ...and it does change something when enabled, or the A/B measured nothing
assert sig() != sig(preserve_circulation=True) assert sig() != sig(preserve_circulation=True)
# --------------------------------------------------------------------------- #
# homemaker-py-fdp / DESIGN.md §38.15 — constructive_topology must be
# bit-reproducible on BOTH assignment solvers.
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
@pytest.mark.parametrize("solver", ["greedy", "cpsat"])
def test_constructive_topology_is_bit_reproducible(solver):
"""Same seed, same signature -- every time, on either solver.
`assignable` is a set of dom.Node, which hashes by id(), so deriving the
room-slot list by iterating it ordered the slots by memory address. That
varies between calls in ONE process, and cpsat consumes the slot order as
its model's variable order, so it returned a different equally-optimal
labelling each run. Greedy never noticed because it re-sorts with `-idx[L]`
as a unique tiebreak.
Repetition in-process is what catches this class: allocation patterns
differ between calls, so id()-derived order changes without any seed
changing.
"""
from homemaker_layout import programme
reqs = programme.load_programme_dir(str(HARBOR))
types = sorted(reqs) + ["C", "O"]
seed = dom.load(str(HARBOR / "init.dom"))
sigs = {
tuple(lf.type for lf in operators.constructive_topology(
seed, reqs, np.random.default_rng(0), types,
min_storeys=programme.storey_minimum(str(HARBOR)),
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
assign_solver=solver).leaves())
for _ in range(4)
}
assert len(sigs) == 1, (
f"assign_solver={solver!r} produced {len(sigs)} distinct leaf-type "
f"signatures from one seed")