From a25dc2cb5962ccf8482a2d33f33580a2ccebd2c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 10:09:14 +0000 Subject: [PATCH] =?UTF-8?q?=C2=A739.4=20completion=20+=20=C2=A739.5=20retr?= =?UTF-8?q?action=20+=20=C2=A739.6:=20the=20usage=20namespace=20is=20NOT?= =?UTF-8?q?=20clean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering "are we clean". Generic namespace: yes. Usage namespace: no. FINISH §39.4. The first sweep missed sites, found by a full re-grep: graph.py's free-area budget, operators.py host-preference / keep-type / repair-candidate, fitness.py's ("l","c","k") public-access test, bubble.py's generic adjacency reference, and -- the important one -- cpsat.py, which was still matching adjacency by raw startswith. graph.code_matches_requirement is now the single public answer to "does this leaf count as the thing the programme asked to be next to", shared by has_adjacency, has_vertical_connection and cpsat. RETRACT §39.5. It concluded 2g7.5's CP-SAT seeder win did not survive the correction. That was wrong. The cause was the missed cpsat matcher above: the exact solver was optimising a different relation than the scorer checked, so a failing test reporting an incomplete sweep was misread as a baseline shift. Re-measured over 6 seeds, cpsat now wins on both programmes (harbor 102/92, maple 156/154). xfail removed. REAL BUG UNDERNEATH: CP-SAT was never deterministic despite num_search_workers=1 and a comment claiming it. neighbors[slot] is a set of dom.Node, which hashes by id() -- a memory address -- so raw iteration made the model-build order vary and CP-SAT returned a different equally-optimal assignment each run (measured 194/180/171/182 over four identical aggregates). sorted() on the slot indices fixes it. Also paired the wall-clock cap with max_deterministic_time (solves run ~124ms against a 2s cap, so nothing was timing out -- latent hazard, not the cause). solve_room_labels is now reproducible on every captured instance; constructive_topology on the cpsat path still is not, filed as homemaker-py-fdp (plausible contributor to b8g). §39.6 THE SECOND NAMESPACE. Usage prefixes b/t/l/k (bedroom/toilet/living/ kitchen) classify programme codes by first letter and stay prefix-based by design, but they are not inert: has_circulation deletes graph edges from them. Four corpus rooms are misclassified by spelling -- la1 "Laundry Room" and li1 "Library Corner" as living, br1 "Staff Room" as bedroom, tr1 "Treatment Room" as toilet. Measured on a health-centre seed: tr1 loses its edge to the adjacent O, br1 loses its edge to t10 "Staff WC" -- both feed the connectivity fails §38 found persisting. Filed homemaker-py-sel; an explicit usage: key is the fix, but it changes fitness for correctly-spelled programmes too so it needs its own A/B. DOCS. README gains a "Room codes and reserved names" section; CLAUDE.md and AGENTS.md gain the same summary for agents. audit_programme_config.py now reports the usage class each code picks up alongside the namespace and satisfiability checks. DESIGN §37.2's note calling the c/o/s quirk "existing product behaviour, not a bug" is annotated as superseded. Corpus audit: zero generic-namespace violations across all ten example programmes. 346 passed, same 7 pre-existing fixture failures, lint unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB --- .beads/issues.jsonl | 2 + AGENTS.md | 18 ++++ CLAUDE.md | 18 ++++ DESIGN.md | 124 +++++++++++++++++++++----- README.md | 38 ++++++++ experiments/audit_programme_config.py | 38 +++++++- src/homemaker_layout/bubble.py | 4 +- src/homemaker_layout/cpsat.py | 35 ++++++-- src/homemaker_layout/fitness.py | 4 +- src/homemaker_layout/graph.py | 12 ++- src/homemaker_layout/operators.py | 9 +- tests/test_operators.py | 35 ++++---- 12 files changed, 282 insertions(+), 55 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6d58c19..b87e4c9 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,6 @@ {"_type":"issue","id":"homemaker-py-ju3","title":"Programme codes share a namespace with the generic c/o/s type prefixes: 14% of harbor-house is silently optional and cr1's declared targets are all discarded","description":"Urb's type system is prefix-based (a type starting with 'c' is circulation, 'o'/'s' is outside) and programme room codes live in the SAME namespace, so any code whose name happens to start with c, o or s is silently reinterpreted as a generic type. Three separate consequences, none announced anywhere in the output:\\n\\n1. graph.check_space_counts line ~530 does 'if code[0].lower() in (\"c\",\"o\",\"s\"): continue' -- the code is SKIPPED ENTIRELY. Never required, never counted, no missing fail, no too-many fail.\\n2. Fitness.get_space_params returns the generic *_circulation / *_outside params BEFORE consulting self.spaces, so declared size/width/proportion are overridden.\\n3. dom.is_circulation / is_outside become true, changing the leaf's value rate, exempting it from crinkliness, and making it supply daylight to neighbours.\\n\\nharbor-house is affected; maple-court, health-centre and programme-house are namespace-clean.\\n\\n cr1 'Common Room with Fireplace' (c): size 80.0 -\u003e 0.0/14.0, width 6.0 -\u003e 2.4, proportion 2.0 -\u003e 1.5, ALL THREE overridden; is_circulation=True so value_rate 50 not 300.\\n of 'Staff Office' x2 (o): width/proportion overridden; is_outside=True; value_rate 100.\\n st1/st2 'Storage' (s): width/proportion overridden; is_outside AND is_circulation True; value_rate 100.\\n\\n5 of 37 room instances (14%) are silently optional. MEASURED CONSEQUENCE: in a 20k-eval run the two cr1 leaves converged to 32.9 and 17.1 m2 against a declared 80 m2, and produced no too-many-spaces fail despite count:1; of/st1/st2 are absent from the result entirely with zero fails, because nothing ever asked for them. Compounds with homemaker-py-2v1: cr1 is the single largest room in the programme and is classified circulation, so the x6 value gap pays the search to shrink it.","design":"Separate the namespaces. Cleanest is an explicit per-space 'class:' key in patterns.config (inside/circulation/outside) defaulting to inside, with the prefix rule used ONLY for untyped generic leaves the search creates -- programme codes then never collide regardless of spelling. A cheaper stopgap is a load-time validation error in programme.load_programme_dir that refuses a programme code starting with c/o/s, which at least converts a silent misread into a loud one. Renaming harbor's four codes would fix that one programme but leaves the trap armed for the next author.","acceptance_criteria":"A programme declaring a code starting with c/o/s either honours its declared params and count, or fails loudly at load. harbor-house re-baselined against its real 37-instance programme, and every DESIGN.md harbor fail count re-stated or annotated as measured against the 32-instance effective programme.","status":"closed","priority":0,"issue_type":"bug","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-08-26T08:26:37Z","created_by":"Claude","updated_at":"2026-08-26T09:05:11Z","started_at":"2026-08-26T08:55:30Z","closed_at":"2026-08-26T09:05:11Z","close_reason":"Shipped as loud validation + harbor rename rather than the class: key (DESIGN.md §39.3). The class: key was deliberately not built: auditing the prefix rule showed l/k/b/t carry adjacency semantics too, so re-plumbing the type system would invalidate the whole corpus for a problem whose damage is the silence, not the convention. programme.validate_codes raises on c/o/s codes from both parse paths; harbor cr1/of/st1/st2 renamed to fr1/ao/gs1/gs2 (neutral unused prefixes, prefix-sharing preserved); migrate_ju3_rename.py migrates pre-rename .dom files. Re-baselined at seed 1/20k: 57 fails on the 32-instance effective programme -\u003e 55 on the real 37-instance one, with all five previously-lost room instances now placed inside their declared sigma bands and zero fails naming them.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-2v1","title":"Connectivity is under-priced ~3x against the circulation-\u003ehabitable value gap: the objective is net-positive on severing the spine","description":"Sharper root cause than homemaker-py-ssz, isolated by the 38.6 A/B (which showed none of the three crinkliness modes removes the deletion incentive). Deleting a circulation leaf merges it into its sibling, converting corridor into habitable area. value_circulation=50 vs value_inside=300, so that is a flat 6x value gain. The only counter-pressure is the 'level N not connected' fail, worth 0.5x under value *= 0.5**len(failures). Break-even needs 0.5^k \u003c 50/300, i.e. k \u003e 2.58 -- severing must cost AT LEAST 3 fails to be net-negative, and it costs 1. Net incentive to sever = 6 * 0.5 = 3.0x in favour; measured 4.06x on a well-lit (q_crink=0.736) circulation leaf, so this is NOT the zero-exposure effect and is not fixable inside quality_uncrinkliness. This is the cleanest explanation of why 'level 0 not connected' and 'level 1 not connected' are still present in evolved-3M-nols-3, the best layout found after 1.7M evals: the search is being paid 3-4x to create them.","design":"Options: (a) emit connectivity fails with a multiplicity \u003e= 3 (cheapest, but stacks with the 1i8 cascade-weighting problem and is a magic number); (b) make the connectivity penalty multiplicative and explicit rather than riding the generic 0.5^n (a dedicated building_factor term, sized from the value-rate gap so it tracks value_circulation/value_inside instead of being hard-coded); (c) revalue circulation as infrastructure -- its worth is that it makes other rooms reachable, which the current per-leaf value rate cannot express; the principled version credits circulation with the access it provides rather than its own floor area. (c) is the architecturally correct one and the biggest change. Recommend measuring (a) first purely to confirm the mechanism (does the 3x threshold flip the deletion test?), then designing (b) or (c) properly.","acceptance_criteria":"Deletion test (experiments/diag_exposure_frontage.py value + the ssz A/B harness) shows lit and buried C/O deletions are no longer rewarded; then harbor-house reaches the 15-fail floor in materially fewer than 1.7M evals AND without 'level 0/1 not connected' in the result.","status":"open","priority":0,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-26T07:37:39Z","created_by":"Claude","updated_at":"2026-08-26T07:37:39Z","dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"homemaker-py-sel","title":"Usage prefixes (b/t/l/k) are a second implicit namespace: 4 corpus rooms get another room's connectivity rules from their spelling","description":"§39.4 separated programme codes from the GENERIC structural types (C/O/S). It did not touch the other namespace sharing the first character: the USAGE prefixes b=bedroom, t=toilet, l=living, k=kitchen. These classify programme codes by first letter and are still prefix-based BY DESIGN (it is how Urb encodes room usage, and unlike the generic rule they never discard a requirement) -- but they are not inert.\\n\\ngraph.has_circulation deletes graph edges from them: a 'bedroom' loses its edges to living/kitchen/bedroom/toilet, a 'toilet' loses its edges to outside/living/kitchen/toilet, and b/t keep their LEAST popular circulation neighbour while l/k keep their MOST popular. fitness.access and the public-access check read them too. So a code that picks one up by accident is silently given another room's connectivity rules -- and connectivity is exactly where §38 located the residual.\\n\\nFour corpus rooms are misclassified by spelling alone:\\n la1 'Laundry Room' -\u003e living (harbor-house, harbor-house-l0, maple-court)\\n li1 'Library Corner' -\u003e living (harbor-house, maple-court)\\n br1 'Staff Room' -\u003e bedroom (health-centre)\\n tr1 'Treatment Room' -\u003e toilet (health-centre)\\n\\nMeasured on a constructed health-centre seed: tr1 (as a toilet) has its edge to the adjacent outside space O stripped from the circulation graph; br1 (as a bedroom) has its edge to t10 'Staff WC' stripped. Both feed has_circulation and therefore the 'N inaccessible usable space' / 'level N not connected' fails.\\n\\nReport it with: python experiments/audit_programme_config.py (usage-prefix section).","design":"Same shape as §39.4: an explicit 'usage:' key in patterns.config (bedroom/toilet/living/kitchen/none), defaulting to the current prefix rule so nothing changes until a programme opts in. UNLIKE §39.4 this alters fitness for programmes that are currently spelled correctly too (any code whose usage is inferred), so it needs its own A/B and re-baseline rather than being folded into §39. Consider also whether the four misclassified corpus rooms should simply declare their real usage once the key exists -- that is the point of the key.","acceptance_criteria":"A programme can declare a room's usage explicitly; the prefix rule applies only where nothing is declared; audit_programme_config reports no unintended usage classification across the corpus; re-baselined with an A/B.","status":"open","priority":1,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-26T10:06:54Z","created_by":"Claude","updated_at":"2026-08-26T10:06:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-ut5","title":"Update the 2v1 acceptance target: harbor's 15-fail reference was measured pre-ju3 against the 32-instance effective programme","description":"DESIGN.md 38.7 fixes the acceptance test for homemaker-py-2v1 as 'harbor-house reaches its known 15-fail floor in materially fewer than 1.7M evals, and without level 0/1 not connected'. That 15-fail figure comes from evolved-3M-nols-3, measured before homemaker-py-ju3 against the 32-instance EFFECTIVE programme (cr1/of/st1/st2 silently dropped or mis-parameterised). Against the real 37-instance programme the number will differ, so the target as written is not measurable any more.\\n\\nNeeded: migrate evolved-3M*.dom with experiments/migrate_ju3_rename.py, rescore against the renamed programme, and restate the 2v1 acceptance figure. The 39.3 re-baseline (55 fails, seed 1, 20k evals) is the new near-term reference but is not the long-budget floor.","acceptance_criteria":"evolved-3M*.dom committed and migrated; its post-ju3 fail count recorded in DESIGN.md; the 2v1 acceptance figure restated against it.","status":"open","priority":1,"issue_type":"task","owner":"noreply@anthropic.com","created_at":"2026-08-26T09:05:23Z","created_by":"Claude","updated_at":"2026-08-26T09:05:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-hxi","title":"Buried circulation and outside space are negative-value: search is rewarded for deleting the circulation spine","description":"Direct consequence of the zero-exposure bug. Measured on a constructed harbor-house seed: deleting a buried O leaf improved the raw score 85x and removed 7 fails; deleting a buried C leaf improved it 62x and removed 6 fails. Programme rooms are held in place ONLY by the missing-space fail cascade, not by contributing value -- deleting a buried k1/da1/m costs +15 fails, so they stay, but nothing positive keeps them. Circulation and outside leaves carry no missing-space requirement, so nothing keeps them at all. Observed live: in a 20k-eval harbor-house run, undivide/core_undivide account for a large share of recorded improvements (16 occurrences in the log) -- the search is literally deleting circulation to score better. This explains three prior negative results as a single mechanism: 18 graded circulation-connectivity (a tie-break signal cannot beat a 60x scalar gradient), 21/22 bridge_circulation (the operator inserts corridor leaves the objective immediately punishes), and the 'level N not connected' hard fails surviving \u003e1M evals in the 3M run.","notes":"Depends on the zero-exposure fix; may need no separate fix if (a)/(c) there restores a value gradient for circulation. Worth re-running the 18 and 21/22 A/Bs afterwards -- both may have been measuring a broken gradient rather than a bad idea.","status":"open","priority":1,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:14:40Z","created_by":"Claude","updated_at":"2026-08-25T23:14:40Z","dependencies":[{"issue_id":"homemaker-py-hxi","depends_on_id":"homemaker-py-ssz","type":"blocks","created_at":"2026-08-25T23:15:12Z","created_by":"Claude","metadata":"{}"},{"issue_id":"homemaker-py-hxi","depends_on_id":"homemaker-py-2v1","type":"blocks","created_at":"2026-08-26T07:37:47Z","created_by":"Claude","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-ssz","title":"Zero-exposure leaves score hard quality=0: the objective assigns no value to any interior room","description":"fitness.quality_uncrinkliness returns a hard 0.0 when a leaf has no daylit wall (area_outside==0 =\u003e crink==0 =\u003e 'if not crink: return 0.0'). Because evaluate_leaf MULTIPLIES factors into quality, and process_storey accumulates 'value += quality * rate * area', such a leaf contributes EXACTLY ZERO value while still adding cost. Measured on the full default construction stack (leaf_sharing, depth_balanced, interior_outside, collapse_insearch), 3 seeds each: harbor-house 46% of interior leaves, health-centre 45%, maple-court 56% are zero-exposure. On a converged 20k-eval harbor-house run (seed 1, 57 fails) 14 of 17 crinkliness fails are zero-exposure, and ~470 m2 of the 721 m2 ground floor plate sits at zero value. This is the mathematically consistent limit of the gaussian (1/crink -\u003e inf), so it is a faithful port, not a porting bug -- but it means the objective's gradient does not describe a good building.","design":"Options, none yet chosen: (a) floor the factor at a small epsilon instead of 0 so buried leaves keep a value gradient and remain rankable; (b) make the gaussian one-sided (clip to 1.0 on the compact side) so being LESS exposed than target is not punished as hard as being over-exposed -- architecturally, a compact well-insulated room is not a defect; (c) exempt circulation/store types from the daylight requirement entirely (uncrinkliness_circulation currently uses the same [5/6, 1.1/3] as habitable rooms, so internal corridors -- completely normal architecture -- are guaranteed failures). Any change here invalidates prior fail-count baselines, so it needs its own A/B and a DESIGN.md section.","acceptance_criteria":"A/B on harbor-house + maple-court at fixed budget showing the chosen variant lowers hard-fail count without inflating soft; DESIGN.md section recording the result; prior baselines re-stated under the new objective.","status":"open","priority":1,"issue_type":"bug","owner":"noreply@anthropic.com","created_at":"2026-08-25T23:14:40Z","created_by":"Claude","updated_at":"2026-08-25T23:14:40Z","dependencies":[{"issue_id":"homemaker-py-ssz","depends_on_id":"homemaker-py-2v1","type":"blocks","created_at":"2026-08-26T07:37:47Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} @@ -33,6 +34,7 @@ {"_type":"issue","id":"homemaker-py-1p0","title":"Geometry inner loop: full-objective equal-offset ratio optimiser","description":"DESIGN.md §5.1, §7 Phase 1. Productionise experiments/optimize_fullfitness.py into homemaker: optimise(topology, x0=None) -\u003e (geometry, fitness). DOF = equal-offset division ratios of free branches (solver.free_branches, lowest-storey cut ownership), clipped to [eps, 1-eps]. Objective = full oracle fitness (never a proxy — §4.2 falsified). Must support warm-start x0 (§5.6) and a population/batch evaluation mode so each iteration scores via one batched oracle call (§4.6).","acceptance_criteria":"Reproduces or exceeds §4.5 gains (x1.24–x1.67, no new failures) on 2f45907, candidate-002, c964435; works as a library call on any corpus .dom","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T08:46:31Z","started_at":"2026-06-12T00:14:19Z","closed_at":"2026-06-12T08:46:31Z","close_reason":"innerloop.optimise() lands: batched CMA-ES sigma ladder (0.05/0.15, IPOP popsize doubling, deterministic seeding) over equal-offset free-branch ratios vs full oracle fitness; warm-start x0 supported. Acceptance vs unprojected originals: x1.65/x1.66/x1.58 against bars x1.24/x1.67/x1.59, no new failures, 46 oracle calls vs NM's 200. Two near-bar results accepted as reproduced-within-noise (1% tol) — draw spread brackets the single-NM-draw bars; approved by Bruno 2026-06-12. Gotchas: equal-offset projection of legacy unequal cuts loses fitness/adds failures (midpoint projection used); pycma seed=0 means clock-seeded.","dependencies":[{"issue_id":"homemaker-py-1p0","depends_on_id":"homemaker-py-av5","type":"blocks","created_at":"2026-06-12T00:39:33Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"homemaker-py-8cs","title":"Experiment: warm-vs-cold start of inner loop (Lamarckian inheritance)","description":"DESIGN.md §5.6, §4.6. Warm-starting a child topology's inner loop from the parent's optimised ratios is the main lever for cutting per-topology cost (~3 min/topology cold). Apply single topology mutations to optimised corpus designs, re-optimise warm (surviving cuts keep values, new cuts get heuristic defaults) vs cold, compare oracle-call counts to convergence at equal final fitness.","acceptance_criteria":"Speedup factor measured across \u003e=10 mutated topologies; decision recorded (expect order-of-magnitude; if \u003c2x, revisit §4.6 Phase-2 scoping)","notes":"Experiment script committed (experiments/warm_vs_cold.py, 1cc86c8) and machinery validated oracle-free; one mutated child scored through the oracle OK. Waiting on homemaker-py-gp2 reference run to finish, then execute under URB_NO_OCCLUSION=1 (3 parents x 400 evals + 12 children x 2 x 200 evals, ~1.5-2 h oracle time). Default budgets: parent 400, child 200; target = evals to 95% of best final.","status":"closed","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T11:44:45Z","closed_at":"2026-06-12T11:44:45Z","close_reason":"Measured (URB_NO_OCCLUSION=1, parent budget 400, child 200, 12 single mutations across 3 designs): cold start reached 95% of warm final in 0/12 cases within budget — speedup unbounded at practical budgets; warm finals beat cold finals x1.2-x4 in 12/12; 6/12 warm starts were within 95% at 1 eval (near-neutral mutations). Decision: Lamarckian warm-starting is MANDATORY in the memetic driver (homemaker-py-b39), not an optimisation; cold starts produce strictly worse geometry at equal budget. Note: 2 undivides were exactly fitness-neutral (same-type merge == Merge_Divided equivalence) — locality datum for homemaker-py-nyb.","dependencies":[{"issue_id":"homemaker-py-8cs","depends_on_id":"homemaker-py-1p0","type":"blocks","created_at":"2026-06-12T00:39:34Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"homemaker-py-av5","title":"Batched oracle: score many .dom files per invocation","description":"oracle.py currently scores one .dom per urb-fitness.pl call (~1.65 s/dom). DESIGN.md §4.6: batching amortises Perl startup to ~0.99 s/dom and is required so population/batch optimisers can score a whole generation in one oracle call. Extend oracle.py with a batch API: write N .dom files, one perl invocation, parse N .score/.fails pairs. Keep the single-file path for compatibility.","acceptance_criteria":"Batch of 35 corpus files scores in one perl invocation; per-file results identical to single-file calls; measured s/dom reported","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:56Z","created_by":"Bruno Postle","updated_at":"2026-06-12T00:14:06Z","started_at":"2026-06-11T23:50:40Z","closed_at":"2026-06-12T00:14:06Z","close_reason":"score_batch() lands in oracle.py; 35-file corpus parity verified single-vs-batch (1e-12 rel fitness, exact fail sets); 0.98 s/dom batched vs 1.27 single, x1.30","dependency_count":0,"dependent_count":1,"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-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":"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","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} diff --git a/AGENTS.md b/AGENTS.md index 9390d72..8d5c8df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,24 @@ bd close # Complete work - Run `bd prime` for detailed command reference and session close protocol - Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files +### Room-code namespaces (DESIGN.md §39.4/§39.6) + +Leaf types share a first character across three namespaces: + +- **`C` / `O` / `S`** — generic structural types (circulation / outside / sahn), + uppercase, reserved. A programme code spelled exactly one of these is rejected + at load. +- **programme room codes** — lowercase, may start with *any* letter. The generic + tests match `C`/`O`/`S` exactly, so `cr1` is a room, not circulation. +- **usage prefixes `b`/`t`/`l`/`k`** — bedroom / toilet / living / kitchen, + still matched by first letter *by design*. `graph.has_circulation` strips graph + edges from them, so a code beginning with one inherits that room's + connectivity rules whether or not intended (`homemaker-py-sel`). + +When adding or editing a programme, run +`python experiments/audit_programme_config.py` — it reports reserved-name +collisions, the usage class each code picks up, and per-room-spec satisfiability. + ## Session Completion **When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. diff --git a/CLAUDE.md b/CLAUDE.md index 8ad528e..5ed52c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,24 @@ bd close # Complete work - Run `bd prime` for detailed command reference and session close protocol - Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files +### Room-code namespaces (DESIGN.md §39.4/§39.6) + +Leaf types share a first character across three namespaces: + +- **`C` / `O` / `S`** — generic structural types (circulation / outside / sahn), + uppercase, reserved. A programme code spelled exactly one of these is rejected + at load. +- **programme room codes** — lowercase, may start with *any* letter. The generic + tests match `C`/`O`/`S` exactly, so `cr1` is a room, not circulation. +- **usage prefixes `b`/`t`/`l`/`k`** — bedroom / toilet / living / kitchen, + still matched by first letter *by design*. `graph.has_circulation` strips graph + edges from them, so a code beginning with one inherits that room's + connectivity rules whether or not intended (`homemaker-py-sel`). + +When adding or editing a programme, run +`python experiments/audit_programme_config.py` — it reports reserved-name +collisions, the usage class each code picks up, and per-room-spec satisfiability. + ## Session Completion **When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. diff --git a/DESIGN.md b/DESIGN.md index 584a32e..8cb2b82 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4024,8 +4024,11 @@ FAIL_THRESHOLD inversions of `quality_size`/`quality_width`/ formulas by construction, not reimplemented magic numbers: same `conf`/ `get_space_params` lookups `fitness.py` uses, including the "any type code starting with 'c' or 's'/'o' hits the circulation/outside branch, not its own -programme params" quirk — confirmed this is existing product behaviour, not a -bug, by reading `get_space_params`/`quality_size` together). Regions compose +programme params" quirk — confirmed at the time as existing product behaviour, +not a bug, by reading `get_space_params`/`quality_size` together. **SUPERSEDED +by §39.4**: that quirk was a real bug, it silently dropped 14% of harbor-house's +programme, and the generic-type tests now match `C`/`O`/`S` exactly, so a +programme code takes its declared params whatever letter it starts with). Regions compose bottom-up through the slicing tree: a node's cut ALWAYS sums its two children's contributions into the node's own "w" (`edge0+edge2`) dimension, with "h" (`edge1+edge3`) the shared/cross dimension — a fixed convention of @@ -5240,30 +5243,107 @@ measured against the 32-instance effective programme. They remain valid relative to each other but are not comparable to post-§39.3 numbers; treat 58 as the new harbor reference point at this budget. -### 39.5 Fallout: `2g7.5`'s CP-SAT seeder win does not survive the correction +### 39.5 A false alarm on `2g7.5`, and the real bug underneath it — CORRECTED -`§37.7` recorded a real, low-noise seeder-level win for `assign_solver="cpsat"` -on harbor-house, guarded by -`test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency`. That test now -fails, and the cause is the corrected programme, not the solver. Measured over -6 seeds: +**This section previously concluded that `2g7.5`'s CP-SAT seeder win did not +survive §39.4. That conclusion was wrong and is retracted.** + +The symptom was real: after tightening, the harbor A/B flipped to greedy 102 / +cpsat 114, and a control on namespace-clean maple-court still showed cpsat +winning — which looked like "harbor's programme changed, the solver is fine". +It was not. **`cpsat._matches` was still matching adjacency by raw +`startswith`** while `graph.has_adjacency` had been moved to the generic-aware +matcher, so the exact solver was optimising a *different relation* than the +scorer checked — it still believed a room next to `cr1` satisfied "adjacent to +`c`". The failing test was correctly reporting an incomplete sweep, and it was +misread as a baseline shift. + +Fix: `graph.code_matches_requirement` is now the single public answer to "does +this leaf count as the thing the programme asked to be next to", and `cpsat` +uses it for both its adjacency matcher and its symmetry-breaking grouping. +Re-measured over 6 seeds: | programme | greedy | cpsat | | |---|---|---|---| -| harbor, real 37-instance | 102 | 114 | cpsat loses | -| harbor, old 32-instance effective | 98 | 99 | tie — the "win" was already marginal | -| maple-court (namespace-clean, untouched by §39) | 156 | **144** | **cpsat wins** | +| harbor-house | 102 | **92** | cpsat wins | +| maple-court | 156 | **154** | cpsat wins | -maple-court is the control: CP-SAT's advantage is intact on a programme whose -codes never collided, so nothing about the assignment solver regressed. On -harbor the four restored codes — with real adjacency requirements that were -previously dropped — change the assignment problem enough to flip an aggregate -that was a tie to begin with. The harbor test is marked `xfail` with this -reason and a companion test asserts the maple-court result; both -`assign_solver` flags remain default off, as `§37.7` already concluded for -independent reasons. +`2g7.5`'s seeder-level result stands. Both `assign_solver` flags remain default +off for the independent reason §37.7 gives (it does not survive a full +`driver.search` run). + +**The real bug underneath: CP-SAT was never deterministic.** Chasing the flip +turned up that `solve_room_labels` returned different (equally optimal) +assignments across identical runs — measured 194 / 180 / 171 / 182 over four +identical 10-seed aggregates. `num_search_workers = 1` was set with the comment +"determinism (same inputs -> same result)", but that is not sufficient: + +- `max_time_in_seconds` is a **wall-clock** cap, so a timeout returns whatever + branch-and-bound had reached — load-dependent by construction. Now paired + with `max_deterministic_time`, a load-independent work-unit budget. (Measured + aside: solves finish in ~124 ms mean / 305 ms max against a 2 s cap, so + nothing was actually timing out — this was a latent hazard, not the cause.) +- The actual cause: `neighbors[slot]` is a **set of `dom.Node`**, and `Node` is + `@dataclass(eq=False)`, so it hashes by `id()` — a memory address. Iterating + it raw made the order the model was built in vary run to run, and among + several equally-optimal assignments CP-SAT returned a different one each + time. `sorted()` on the integer slot indices makes the model canonical. This + is the same id-keying hazard `geometry._cache` already carries a warning for. + +After both fixes `solve_room_labels` is reproducible on every captured +instance, and cpsat beats greedy on 4 of 4 repeats. **`constructive_topology` +as a whole is still not bit-reproducible on the cpsat path** — something +upstream of the solver in `_assign_adjacency_aware` still varies (greedy *is* +reproducible; the solver in isolation now is too). Filed as +`homemaker-py-fdp`; it is a plausible contributor to `homemaker-py-b8g` +(parallel-run non-determinism). The A/B test now averages three repeats rather +than asserting on one, so it states what is actually claimed — better in +aggregate — instead of being flaky by construction. + +**Lesson worth keeping:** when a matching rule is tightened, every consumer of +that rule has to move at once. A solver optimising yesterday's relation against +today's scorer looks exactly like a baseline shift. + +### 39.6 The second namespace: usage prefixes are still implicit — NOT clean + +§39.4 separated **programme codes** from the **generic structural types** +(`C`/`O`/`S`). It did not touch the other namespace sharing the same first +character: the **usage prefixes** `b` bedroom, `t` toilet, `l` living, +`k` kitchen. These classify *programme codes* by first letter and are still +prefix-based **by design** — it is how Urb encodes room usage, and unlike the +generic rule they never discard a requirement. + +They are not inert, though. `graph.has_circulation` deletes graph edges from +them: a "bedroom" loses its edges to living/kitchen/bedroom/toilet, a "toilet" +loses its edges to outside/living/kitchen/toilet, and b/t keep their *least* +popular circulation neighbour while l/k keep their *most* popular. +`fitness.access` and the public-access check read them too. So a code that +picks one up by accident is silently given another room's connectivity rules — +and connectivity is exactly where §38 found the residual. + +`experiments/audit_programme_config.py` now reports this. Four corpus rooms are +misclassified by spelling alone: + +| code | name | given usage | +|---|---|---| +| `la1` | Laundry Room | **living** (harbor-house, harbor-house-l0, maple-court) | +| `li1` | Library Corner | **living** (harbor-house, maple-court) | +| `br1` | Staff Room | **bedroom** (health-centre) | +| `tr1` | Treatment Room | **toilet** (health-centre) | + +Measured consequences on a constructed health-centre seed: `tr1` "Treatment +Room", treated as a toilet, has its edge to the adjacent outside space `O` +stripped from the circulation graph; `br1` "Staff Room", treated as a bedroom, +has its edge to `t10` "Staff WC" stripped. Both feed `has_circulation` and +therefore the `N inaccessible usable space` / `level N not connected` fails. + +**So the honest answer to "is it clean now" is: the generic namespace is +(zero violations across all ten example programmes, asserted by +`test_scoring_is_invariant_under_programme_code_spelling`); the usage namespace +is not.** Filed as `homemaker-py-sel`. The fix is the same shape as §39.4 — an +explicit `usage:` key in `patterns.config` defaulting to the prefix rule for +back-compatibility — but unlike §39.4 it changes fitness for programmes that +are *currently spelled correctly* too, so it needs its own A/B and re-baseline +rather than being folded in here. -The general lesson: **any harbor-house A/B decided by a small margin before §39 -was decided against a programme missing 14% of its rooms** and is worth -re-checking if anything depends on it. diff --git a/README.md b/README.md index 64539c2..eb47b55 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,41 @@ search then only explores topology + types + adjacency. - `src/homemaker_layout/bubble.py` — 3D bubble-diagram adjacency fitness-signal prototype (DESIGN.md §27); validated null, not wired into `fitness.py` — reference only. + +## Room codes and reserved names + +Leaf types live in **three namespaces that share a first character**. Only the +first is enforced; the other two are conventions the fitness function reads, so +a room's *spelling* can change how it is scored. + +**1. Generic structural types — `C`, `O`, `S` (reserved).** The leaves the +search itself creates: `C` circulation, `O` outside, `S` sahn (an outside court +that also serves as circulation). Always uppercase. A programme code spelled +exactly `C`, `O` or `S` is rejected at load. + +**2. Programme room codes — anything else, lowercase.** `k1`, `b1`, `cr1`, +`of`, and single-character codes like `r` or `t`. These may start with **any** +letter: since DESIGN.md §39.4 the generic tests match `C`/`O`/`S` exactly, so +naming a room `cr1` no longer makes it circulation. (Before that fix it did — +and silently dropped it from the required-space check entirely.) + +**3. Usage prefixes — `b` bedroom, `t` toilet, `l` living, `k` kitchen.** +Still matched by first letter, deliberately: this is how Urb encodes room usage. +`graph.has_circulation` deletes graph edges based on them (a "bedroom" loses its +edges to living/kitchen/bedroom/toilet; a "toilet" loses its edges to +outside/living/kitchen/toilet), and the access and public-access checks read +them too. + +**So a code beginning with `b`/`t`/`l`/`k` inherits that room's connectivity +rules whether or not you meant it** — `la1` "Laundry Room" is treated as a +living room, `tr1` "Treatment Room" as a toilet. This is a known wart +(`homemaker-py-sel`, DESIGN.md §39.6); an explicit `usage:` key is the planned +fix. Until then, check any new programme with: + +```bash +python experiments/audit_programme_config.py +``` + +which reports reserved-name collisions, the usage class each code picks up, and +whether each room's size/width/proportion/crinkliness targets are mutually +satisfiable at all. diff --git a/experiments/audit_programme_config.py b/experiments/audit_programme_config.py index 5f416f8..fc79157 100644 --- a/experiments/audit_programme_config.py +++ b/experiments/audit_programme_config.py @@ -99,6 +99,39 @@ def audit_code(fit: fitness.Fitness, code: str, height: float, return result +# The SEMANTIC (usage) prefixes. Unlike the generic types these classify +# PROGRAMME CODES by first letter, and they are still prefix-based by design — +# it is how Urb encodes room usage. graph.has_circulation strips edges based on +# them (a "bedroom" loses its edges to living/kitchen/bedroom/toilet; a "toilet" +# loses its edges to outside/living/kitchen/toilet), and fitness.access / +# public-access read them too. So a code that picks one up by accident is +# silently given another room's connectivity rules. +USAGE_PREFIXES = {"b": "bedroom", "t": "toilet", "l": "living", "k": "kitchen"} + + +def audit_usage(progdir: str) -> list[tuple[str, str, str]]: + """Report which programme codes acquire a usage class from their spelling.""" + reqs = programme.load_programme_dir(progdir) + hits = [(c, USAGE_PREFIXES[c[:1].lower()], reqs[c].name) + for c in sorted(reqs) if c[:1].lower() in USAGE_PREFIXES] + if not hits: + print(f"=== {Path(progdir).name}: no code carries a usage prefix\n") + return [] + print(f"=== {Path(progdir).name}: {len(hits)} code(s) carry a usage prefix") + for code, usage, name in hits: + # crude but useful: does the human-readable name agree with the usage? + agrees = usage[:3] in (name or "").lower() or { + "toilet": ("wc", "bathroom", "toilet", "ensuite"), + "bedroom": ("bedroom",), "living": ("living", "lounge"), + "kitchen": ("kitchen",)}.get(usage, ()) + ok = any(w in (name or "").lower() for w in ( + agrees if isinstance(agrees, tuple) else (usage,))) + flag = "" if ok else " <-- name disagrees with the usage it is given" + print(f" {code:<6} -> {usage:<8} (name: {name}){flag}") + print() + return hits + + def audit_namespace(progdir: str) -> int: """Report programme codes that collide with the generic type prefixes. @@ -201,9 +234,12 @@ def main() -> None: dirs = [args.progdir] if args.progdir else [ "examples/harbor-house", "examples/maple-court", "examples/health-centre", "examples/programme-house"] - print("### namespace collisions (generic c/o/s type prefixes)\n") + print("### namespace collisions (generic C/O/S structural types)\n") for d in dirs: audit_namespace(d) + print("### usage prefixes (b/t/l/k -- still prefix-based, by design)\n") + for d in dirs: + audit_usage(d) print("### per-room-spec satisfiability\n") for d in dirs: audit(d, args.verbose) diff --git a/src/homemaker_layout/bubble.py b/src/homemaker_layout/bubble.py index ef34045..759f7f0 100644 --- a/src/homemaker_layout/bubble.py +++ b/src/homemaker_layout/bubble.py @@ -87,8 +87,8 @@ def requirement_graph(reqs: dict[str, SpaceReq]) -> nx.Graph: continue for node_id in instances[code]: for adj_code in req.adjacency: - low = adj_code[0].lower() - if low in ("c", "o", "s"): + low = adj_code.lower() + if low.upper() in dom.GENERIC_TYPES: G.add_edge(node_id, hub(low), weight=1.0) elif adj_code in instances: targets = instances[adj_code] diff --git a/src/homemaker_layout/cpsat.py b/src/homemaker_layout/cpsat.py index 48c5f2a..77ed4e6 100644 --- a/src/homemaker_layout/cpsat.py +++ b/src/homemaker_layout/cpsat.py @@ -46,6 +46,7 @@ def solve_room_labels( neighbors: dict[Hashable, set], context_types: dict[Hashable, set[str]], time_limit_s: float = 2.0, + deterministic_limit: float = 4.0, ) -> dict[Hashable, str] | None: """Assign each of ``codes`` to one of ``slots``, maximising satisfied secondary adjacency requirements. @@ -90,7 +91,12 @@ def solve_room_labels( model.Add(sum(x[i, s] for i in range(k)) <= 1) def _matches(code: str, prefix: str) -> bool: - return code.lower().startswith(prefix.lower()) + # §39.4: shared with graph.has_adjacency so the exact solve optimises + # exactly what the scorer checks. A generic requirement ("c"/"o"/"s" — + # how programmes name circulation/outside) matches only the generic + # types, not every programme code that starts with that letter. + from .graph import code_matches_requirement + return code_matches_requirement(code, prefix) # Symmetry breaking (homemaker-py-2g7.5, measured necessary on # harbor-house: several unrelated same-requirement codes, e.g. four "t" @@ -106,8 +112,8 @@ def solve_room_labels( referenced = {a.lower() for c in codes for a in (reqs.get(c).adjacency if reqs.get(c) else [])} def _is_referenced(code: str) -> bool: - cl = code.lower() - return any(cl.startswith(r) for r in referenced) + from .graph import code_matches_requirement + return any(code_matches_requirement(code, r) for r in referenced) groups: dict[tuple, list[int]] = {} for i, code in enumerate(codes): @@ -138,7 +144,16 @@ def solve_room_labels( if any(_matches(t, adj_lower) for t in fixed): neighbor_ok_cache[key] = 1 return 1 - nbr_idxs = [idx[nb] for nb in neighbors.get(slot, ()) if nb in idx] + # sorted(): ``neighbors[slot]`` is a SET, and its members are usually + # ``dom.Node``s, which are ``@dataclass(eq=False)`` and therefore hash by + # id() -- a memory address. Iterating it raw makes the order the model is + # built in vary run to run, so among several equally-optimal assignments + # CP-SAT returns a different one each time. That, not the time limit, was + # the real source of the "non-deterministic, system-load dependent" + # behaviour §37.7 recorded (measured 194/180/171/182 over four identical + # 10-seed runs; solves finish in ~124 ms against a 2 s cap, so nothing was + # ever timing out). Sorting the integer indices makes the model canonical. + nbr_idxs = sorted(idx[nb] for nb in neighbors.get(slot, ()) if nb in idx) matches = [x[j, ns] for ns in nbr_idxs for j, code in enumerate(codes) if _matches(code, adj_lower)] if not matches: @@ -173,8 +188,18 @@ def solve_room_labels( model.Maximize(sum(sat_vars)) solver = cp_model.CpSolver() + solver.parameters.num_search_workers = 1 + # Determinism (homemaker-py-b8g / DESIGN.md §39.5). ``num_search_workers=1`` + # alone does NOT give it: a WALL-CLOCK cap makes the returned solution + # load-dependent, because a timeout returns whatever branch-and-bound had + # reached by then. That is exactly the "non-deterministic, system-load + # dependent" behaviour §37.7 observed, and it made the seeder A/B flaky + # (10-seed aggregate measured 194/180/171/182 across four identical runs). + # ``max_deterministic_time`` is a work-unit budget, independent of machine + # speed and load, so the same inputs give the same answer; the wall-clock + # cap stays as a pathological-case backstop only. + solver.parameters.max_deterministic_time = deterministic_limit solver.parameters.max_time_in_seconds = time_limit_s - solver.parameters.num_search_workers = 1 # determinism (same inputs -> same result) status = solver.Solve(model) if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE): return None diff --git a/src/homemaker_layout/fitness.py b/src/homemaker_layout/fitness.py index 113705c..3178da2 100644 --- a/src/homemaker_layout/fitness.py +++ b/src/homemaker_layout/fitness.py @@ -1554,7 +1554,9 @@ class Fitness: if self._public_access(leaf, root) is None: return False for nb in G.neighbors(leaf): - if nb.type and nb.type[0].lower() in ("l", "c", "k"): + # "l"/"k" are SEMANTIC programme-code prefixes; C is a generic + # circulation leaf. Two namespaces, two tests (§39.4). + if nb.type == "C" or (nb.type and nb.type[0].lower() in ("l", "k")): return True return False diff --git a/src/homemaker_layout/graph.py b/src/homemaker_layout/graph.py index e3dce17..156584a 100644 --- a/src/homemaker_layout/graph.py +++ b/src/homemaker_layout/graph.py @@ -426,6 +426,16 @@ def _codes_match_prefix(codes: list[str], tc) -> bool: return any(c.lower().startswith(tc) for c in codes) +def code_matches_requirement(code: str, target_code: str) -> bool: + """True if one room ``code`` satisfies an ``adjacency:`` requirement. + + The single place that answers "does this leaf count as the thing the + programme asked to be next to". Shared with :mod:`homemaker_layout.cpsat` + so the exact solver optimises the same relation the scorer checks. + """ + return _codes_match_prefix([code], _adjacency_target(target_code)) + + def _adjacency_target(target_code: str): """Resolve one ``adjacency:`` entry to a matcher. @@ -775,7 +785,7 @@ def substrate_readiness( free_area = sum( req.size * req.count for code, req in reqs.items() - if code[0].lower() not in ("c", "o", "s") and req.level is None + if not is_generic(code) and req.level is None ) upper_free = free_area * (n_storeys - 1) / n_storeys if n_storeys > 0 else 0.0 required_upper_area = upper_levels + upper_free diff --git a/src/homemaker_layout/operators.py b/src/homemaker_layout/operators.py index ac6ebb6..2030bd3 100644 --- a/src/homemaker_layout/operators.py +++ b/src/homemaker_layout/operators.py @@ -338,10 +338,9 @@ def mutate_place_missing(root: dom.Node, rng: np.random.Generator, for leaf in lvls[li].leaves(): if not leaf.type: continue - t0 = leaf.type[0].lower() - if t0 == "o": + if leaf.type == "O": pref = 0 - elif t0 in ("c", "s"): + elif leaf.type in ("C", "S"): pref = 2 elif leaf.type in reqs: continue @@ -353,7 +352,7 @@ def mutate_place_missing(root: dom.Node, rng: np.random.Generator, best_pref = min(p for p, _, _ in cands) pool = [(a, lf) for p, a, lf in cands if p == best_pref] _, host = max(pool, key=lambda x: x[0]) - keep = host.type if host.type and host.type[0].lower() != "o" else "O" + keep = host.type if host.type not in dom.GENERIC_OUTSIDE else "O" else: # No safe host on the required storey — split its largest leaf and # preserve that leaf's type on the large side. @@ -469,7 +468,7 @@ def _shape_failing(leaf: dom.Node, fit) -> bool: passes. Generic circulation/outside/sahn leaves are never candidates — they absorb slack by design (solver.py ``min_width_generic``), not a repair target.""" - if not leaf.type or leaf.type[0].lower() in "cos": + if dom.is_generic(leaf.type) or not leaf.type: return False from . import fitness as _fit_mod diff --git a/tests/test_operators.py b/tests/test_operators.py index 7dc2a4c..9754eda 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -513,16 +513,6 @@ def test_construction_assign_cpsat_yields_valid_seed(): canonical(root) -@pytest.mark.xfail( - reason="§39.5: this win was measured against harbor's pre-§39.4 EFFECTIVE " - "programme, which silently dropped cr1/of/st1/st2 (14% of the rooms). " - "With those restored the aggregate flips (measured greedy 102 / cpsat " - "114 over 6 seeds; it was 98/99 -- a tie -- on the 32-instance " - "programme). Not a regression in the solver: on namespace-clean " - "maple-court cpsat still wins, which the companion test asserts. " - "Both assign_solver flags remain default off (§37.7).", - strict=False, -) @pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available") def test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency(): # homemaker-py-2g7.5: CP-SAT solves the same room-labelling decision the @@ -554,9 +544,16 @@ def test_assign_cpsat_matches_or_beats_greedy_secondary_adjacency(): # the comparison is on the aggregate over several seeds, not every seed # individually — measured on harbor-house (10 seeds): cpsat wins on # most, ties on a few, loses on rare ones, net ~13% fewer total fails. - greedy = secondary_fails("greedy") - cpsat = secondary_fails("cpsat") - assert sum(cpsat) < sum(greedy) + # The cpsat path is not yet bit-reproducible (homemaker-py-fdp): the solver + # itself is deterministic, but something upstream of it in + # _assign_adjacency_aware still varies, so a single 10-seed aggregate can + # straddle greedy's (deterministic) value. Averaging three repeats asserts + # what is actually claimed -- better IN AGGREGATE -- instead of being flaky + # by construction. Measured after §39.4: greedy 189, cpsat 185/177/180/182. + greedy = sum(secondary_fails("greedy")) + cpsat_runs = [sum(secondary_fails("cpsat")) for _ in range(3)] + mean_cpsat = sum(cpsat_runs) / len(cpsat_runs) + assert mean_cpsat < greedy, f"cpsat {cpsat_runs} (mean {mean_cpsat}) vs greedy {greedy}" def test_reassign_noop_without_reqs(): @@ -909,12 +906,14 @@ def test_mutate_bridge_circulation_prefers_free_leaf_over_required_room(): @pytest.mark.skipif(not (HARBOR.parent / "maple-court").is_dir(), reason="maple-court not available") def test_assign_cpsat_beats_greedy_on_a_namespace_clean_programme(): - """§39.5 companion: CP-SAT's seeder-level advantage is intact on a - programme whose codes never collided with the generic type prefixes. + """§39.5 companion: the same property on a second, namespace-clean + programme. - This is what shows the §39.4 tightening did not regress the solver — the - harbor result above moved because harbor's programme changed (4 codes it had - been silently dropping came back), not because assignment got worse. + Kept because it was this pair that caught an incomplete §39.4 sweep: + ``cpsat._matches`` was still matching adjacency by raw prefix after + ``graph.has_adjacency`` had been tightened, so the exact solver was + optimising a different relation than the scorer checked. Two programmes + make that class of drift visible instead of looking like noise. """ import copy