Phase 6 §11.4: graded high-fail objective — negative result (c4c.4)
Implement a graded proximity comparator key (-n_fails, grade, fitness) behind a default-off use_grade flag: fitness._leaf_grade / score_with_grade sum f/FAIL_THRESHOLD over failing per-leaf quality factors; scalar fitness and fail count stay untouched so the inner-loop 0.5^n cliff (§5.4) is unaffected (0/9 regression check: PASS). Read once per child in driver._evaluate off the already-optimised tree; threaded through search_staged (Stage 2 only). Harbor staged A/B (20000 evals, seeds 0/1/2): lex 95/96/106 (mean 99.0) vs lex+grade 99/98/102 (mean 99.7) — grade wins 1/3, no plateau escape. Premise falsified: within a fixed fail-tier 0.5^n is constant so fitness still spans ~6 orders of magnitude; grade above fitness displaces that working signal. Verdict: reject; lexicographic (-n_fails, fitness) stands. Flag kept default-off for reproducibility / possible reuse as a §11.5 diversity signal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
bbbbdedde8
commit
ed2869074b
7 changed files with 228 additions and 31 deletions
|
|
@ -16,7 +16,7 @@
|
|||
{"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}
|
||||
{"id":"homemaker-py-s44","title":"Adjacency-aware constructive seeding (cut adjacency/access fails)","description":"Follow-up to homemaker-py-c4c.2. constructive_topology currently assigns room types to leaves at RANDOM, ignoring each space's adjacency requirement. On harbor this leaves 8 adjacency + 13 access fails in the seeded design. Cluster each required room near its required neighbour (esp. circulation c) at construction time — e.g. assign rooms to leaves whose sibling/parent is C, or grow the tree so each room lands adjacent to a circulation spine. Should directly cut the adjacency+access fail load that now dominates the complete-design quality-fail regime (DESIGN.md §11.2 verdict).","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-17T21:50:01Z","created_by":"Bruno Postle","updated_at":"2026-06-17T21:50:01Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-c4c.5","title":"Topology diversity: structural niching + restarts (replace fitness-scalar dedup)","description":"The population dedups on the FITNESS SCALAR (driver.py:174, abs(fitness) within 1e-9) and replaces worst-by-key. There is no structural/topological diversity preservation, no restarts, no islands. On a rugged combinatorial landscape this converges prematurely — and it is the root cause of the blank-slate gap (§7 Phase 2 verdict): a single mutation chain loses to urb-evolve's random-population diversity (init.dom: memetic 18 fails vs urb-evolve 6).\nAdd: (1) a topology signature (canonical tree hash / partition signature) so 'same topology, different geometry' is detectable and niching is by STRUCTURE not score; (2) diversity-preserving replacement (crowding / niching); (3) restarts or a small island model so blank-slate exploration matches urb-evolve's upfront diversity.","design":"A cheap topology-signature hash (string-encode the per-level tree + types) unblocks niching without waiting for the full canonical encoding; the canonical Polish encoding (homemaker-py-9gp) is the principled long-term signature and makes (a|b)|c == a|(b|c) collapse exactly. Wire signature into admit() in place of / alongside the fitness-scalar guard.","acceptance_criteria":"On blank-slate programme-house, memetic reaches \u003c=6 fails (matching/beating urb-evolve) at equal native-fitness budget; population structural diversity quantified (distinct topology signatures over time) before/after; recorded in DESIGN.md §11.x + bead notes.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-17T19:12:54Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:12:54Z","dependencies":[{"issue_id":"homemaker-py-c4c.5","depends_on_id":"homemaker-py-9gp","type":"relates-to","created_at":"2026-06-17T20:14:46Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-c4c.5","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T20:12:53Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-c4c.5","depends_on_id":"homemaker-py-c4c.2","type":"blocks","created_at":"2026-06-17T20:12:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-c4c.4","title":"Graded high-fail objective (gradient in the high-fail regime)","description":"Phase 4 (homemaker-py-yg5) chose lexicographic (-n_fails, fitness) — correct for not being FOOLED by the 0.5^n cliff (§4.9). But lexicographic-by-TOTAL-count gives almost zero selection signal in the high-fail regime: on harbor every candidate sits at ~49-74 fails, so neighbours are indistinguishable and the search has no gradient to climb. There is no partial credit for a size-fail that is nearly in range, nor for covering one more required requirement. §7 predicted penalty reshaping would 'flatten the fail cliff' for blank-slate; lexicographic did not deliver that for high counts.\nAdd a graded objective for the high-fail regime: continuous proximity per unsatisfied constraint (how close a size/width/proportion is to its band) and/or count of DISTINCT unsatisfied requirements with sub-credit, used as a tie/secondary key beneath fail-count. Must preserve: (a) inner-loop 0.5^n cliff protection (§5.4) — inner loop unchanged; (b) the missing-space hierarchy (§6) — must not make dropping a room attractive.","design":"Likely a third comparison key: (-n_fails, -n_distinct_unsatisfied_or_proximity_sum, fitness). Or a soft margin inside fail counting only in the outer comparator. Keep the scalar fitness (with 0.5^n) untouched so the inner loop is unaffected. Extends homemaker-py-yg5; reuse experiments/penalty_reshape.py harness.","acceptance_criteria":"Measured escape from a high-fail plateau on harbor and/or blank-slate programme-house that the current lex comparator cannot escape at equal budget; before/after best-fail trajectory recorded in DESIGN.md §11.x + bead notes. Inner-loop cliff protection verified unchanged (re-run the §4.9 inner-loop 0/9-regression check).","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-17T19:12:18Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:12:18Z","dependencies":[{"issue_id":"homemaker-py-c4c.4","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T20:12:18Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-c4c.4","title":"Graded high-fail objective (gradient in the high-fail regime)","description":"Phase 4 (homemaker-py-yg5) chose lexicographic (-n_fails, fitness) — correct for not being FOOLED by the 0.5^n cliff (§4.9). But lexicographic-by-TOTAL-count gives almost zero selection signal in the high-fail regime: on harbor every candidate sits at ~49-74 fails, so neighbours are indistinguishable and the search has no gradient to climb. There is no partial credit for a size-fail that is nearly in range, nor for covering one more required requirement. §7 predicted penalty reshaping would 'flatten the fail cliff' for blank-slate; lexicographic did not deliver that for high counts.\nAdd a graded objective for the high-fail regime: continuous proximity per unsatisfied constraint (how close a size/width/proportion is to its band) and/or count of DISTINCT unsatisfied requirements with sub-credit, used as a tie/secondary key beneath fail-count. Must preserve: (a) inner-loop 0.5^n cliff protection (§5.4) — inner loop unchanged; (b) the missing-space hierarchy (§6) — must not make dropping a room attractive.","design":"Likely a third comparison key: (-n_fails, -n_distinct_unsatisfied_or_proximity_sum, fitness). Or a soft margin inside fail counting only in the outer comparator. Keep the scalar fitness (with 0.5^n) untouched so the inner loop is unaffected. Extends homemaker-py-yg5; reuse experiments/penalty_reshape.py harness.","acceptance_criteria":"Measured escape from a high-fail plateau on harbor and/or blank-slate programme-house that the current lex comparator cannot escape at equal budget; before/after best-fail trajectory recorded in DESIGN.md §11.x + bead notes. Inner-loop cliff protection verified unchanged (re-run the §4.9 inner-loop 0/9-regression check).","notes":"NEGATIVE RESULT (DESIGN.md §11.4). Implemented graded proximity key (-n_fails, grade, fitness) behind use_grade flag (default off): fitness._leaf_grade / score_with_grade sum f/FAIL_THRESHOLD over failing per-leaf quality factors; scalar fitness + fail count untouched. Inner-loop 0/9 regression: PASS (re-ran §4.9 part 1). Harbor staged A/B, 20000 evals, seeds 0/1/2 total fails: lex 95/96/106 (mean 99.0) vs lex+grade 99/98/102 (mean 99.7). Grade wins 1/3, loses 2/3, slightly worse on mean, NO plateau escape. Acceptance criterion (escape lex cannot achieve) NOT met. Root cause: premise falsified — within a fixed fail-tier 0.5^n is constant so fitness still spans ~6 orders of magnitude (1e-37..1e-31), giving lex's secondary fitness key a strong gradient already; grade above fitness DISPLACES it (stalls fail-reducing restructurings), below fitness is inert. High-fail plateau is a topology-basin problem -\u003e defer to §11.5 niching/restarts + 9gp canonical encoding. Code kept default-off for reproducibility / possible reuse as a §11.5 diversity signal.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-17T19:12:18Z","created_by":"Bruno Postle","updated_at":"2026-06-18T21:20:21Z","started_at":"2026-06-18T05:31:17Z","dependencies":[{"issue_id":"homemaker-py-c4c.4","depends_on_id":"homemaker-py-c4c","type":"parent-child","created_at":"2026-06-17T20:12:18Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-g0b","title":"homemaker-fitness: native Python CLI replacement for urb-fitness.pl","description":"We need a Python CLI tool that replicates the behaviour of urb-fitness.pl so we can score .dom files without shelling out to Perl. The tool should: accept .dom file paths as arguments (or glob *.dom in cwd if none given), load patterns.config and costs.config from cwd and parent dir (local overrides project-level), skip scoring if .score and .fails files are already newer than the .dom (unless FORCE_UPDATE env var is set), score each .dom using fitness.Fitness.score_with_fails(), write the score to \u003cdom\u003e.score (40-digit float format), write the failures to \u003cdom\u003e.fails, print the score to stderr. Expose as homemaker-fitness entry point in pyproject.toml and as python -m homemaker_layout.fitness_cmd module. This replaces the oracle.py shelling-out path for Phase 3 native fitness.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-14T12:32:29Z","created_by":"Bruno Postle","updated_at":"2026-06-14T16:17:21Z","started_at":"2026-06-14T12:32:52Z","closed_at":"2026-06-14T16:17:21Z","close_reason":"Implemented as homemaker_layout/fitness_cmd.py with homemaker-fitness entry point; exact score parity verified against urb-fitness.pl on corpus","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-gpx","title":"Native fitness parity gap on multi-storey designs (~3.7%)","description":"During programme-house cold-start runs with the fixed level_add operator, the generated 2-storey design showed native=1.2388e-04 vs oracle=1.1944e-04 (3.7% gap), exceeding the 0.01% rel_tol in test_native_fitness_score_parity. All existing single-storey corpus files pass parity fine (73/73). Hypothesis: a subtle discrepancy in value or cost computation for multi-level trees — candidates are staircase quality, circulation connectivity, or per-storey cost accumulation. To investigate: score a sweep of known multi-storey corpus files natively vs oracle and identify which term diverges.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-14T09:35:34Z","created_by":"Bruno Postle","updated_at":"2026-06-17T17:39:25Z","closed_at":"2026-06-17T17:39:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-hqw","title":"Make homemaker-py standalone: remove dependency on Perl Urb package","description":"Currently tests and fitness scoring depend on the Perl Urb package (urb-fitness.pl) and corpus files in /home/bruno/src/urb/examples/. The tool should be fully standalone and not require any external Perl packages or local urb corpus paths. This includes: bundling or reimplementing any needed reference data, making the native Python fitness the default path, and ensuring tests pass without /home/bruno/src/urb present.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T22:27:54Z","created_by":"Bruno Postle","updated_at":"2026-06-13T22:39:28Z","started_at":"2026-06-13T22:34:20Z","closed_at":"2026-06-13T22:39:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -38,16 +38,16 @@
|
|||
{"id":"homemaker-py-can","title":"Programme width defaults: t3 contradiction (impossible width_inside default)","description":"DESIGN.md §8.2, confirmed in source. t3 (3 m2 WC) has no width spec so inherits width_inside [4.0, 1.0] (Fitness/Base.pm:60) — geometrically impossible; designs 'pass' only by failing size instead. Fix AFTER faithful-port validation (port-faithfully-first policy, §8.1): a sane width default scaled to area (e.g. sqrt(area/proportion)) or per-room widths in patterns.config. Applies to native fitness; optionally upstream to Urb.","acceptance_criteria":"No programme space has a default width incompatible with its target area; corpus re-scored and effect documented","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:01Z","created_by":"Bruno Postle","updated_at":"2026-06-13T21:21:37Z","started_at":"2026-06-13T21:16:11Z","closed_at":"2026-06-13T21:21:37Z","close_reason":"Fixed in get_space_params: when a programme space has no explicit 'width', derive target from sqrt(size/proportion) instead of falling back to width_inside [4.0, 1.0]. Re-scored 35-file corpus: 32 files improved (+1-121%), 5 files lost spurious width fails. All 109 tests pass. Upstream Perl fix tracked as homemaker-py-8fe.","dependencies":[{"issue_id":"homemaker-py-can","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:47Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-yg5","title":"Penalty reshaping: replace 0.5^n while preserving inner-loop protection","description":"DESIGN.md §4.7, §5.4, §7 Phase 4, §8.5. The 0.5^n cliff gives the outer search no gradient and rewards flag-count over geometry, but it also PROTECTS the inner loop from trading into new failures (§4.5). One fitness shape cannot naively be both soft outside and cliff-protected inside. Candidates: cliff-inside-inner-loop only, lexicographic (failure count first, score second), additive/soft, multi-objective Pareto. Must preserve the missing-space failure hierarchy (worse to drop a room than to have a poor one). Measure landscape + search outcomes; this helps Urb today too.","acceptance_criteria":"Chosen scheme documented with measurements: search improves while inner loop still never trades into new failures","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:39:00Z","created_by":"Bruno Postle","updated_at":"2026-06-14T08:16:14Z","started_at":"2026-06-14T07:55:32Z","closed_at":"2026-06-14T08:16:14Z","close_reason":"Implemented lexicographic outer-search comparison (-n_fails, fitness). Inner loop unchanged (0.5^n cliff protection preserved). Experiment penalty_reshape.py confirms 0/9 fail regressions in inner loop and shows lex avoids the 3-fail trap that scalar hits 1/3 of the time. Fixed stale _CHILD_INNER_KW sigmas entry.","dependencies":[{"issue_id":"homemaker-py-yg5","depends_on_id":"homemaker-py-uxz","type":"blocks","created_at":"2026-06-12T00:39:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"id":"homemaker-py-2g5","title":"Rebuild occlusion/daylight/sun subsystem in Python (post-Phase-5, after optimisation fully native)","description":"DESIGN.md §6 port scope — a whole subsystem, not a term. quality_daylight (Leaf.pm:281-296) needs Urb::Misc::Sun + Urb::Field::Occlusion (+CIESky); quality_uncrinkliness also takes the occlusion object. Indoor spaces return 1 for daylight; cost is outdoor spaces + crinkliness. Port Sun_horizontal (262980-minute normalisation) and the occlusion wall set from Dom-\u003eWalls.","acceptance_criteria":"Daylight and crinkliness factors match Perl (float tolerance) across the corpus, including multi-storey cases","notes":"Re-scoped 2026-06-12: occlusion disabled in the Urb oracle instead of ported (see homemaker-py-gp2). Native fitness ships with simple crinkliness (illumination factor = 1, in homemaker-py-gnw). This issue is now the eventual Python occlusion rebuild, only after optimisation works entirely in Python. Restores outdoor-daylight and shaded-wall selection pressure.\nReframed 2026-06-17: orthogonal to epic homemaker-py-c4c. This is fitness FIDELITY (restoring daylight + shaded-wall selection pressure to match Perl), not search CAPABILITY — it changes what 'good' means, not the search's ability to find good. It will NOT improve final designs in the sense currently sought. Stays P4, deferred until the topology-search-quality epic lands and optimisation is fully native.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:25Z","created_by":"Bruno Postle","updated_at":"2026-06-17T19:14:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
|
||||
{"_type":"memory","key":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."}
|
||||
{"_type":"memory","key":"homemaker-py-pythonpath-set-pythonpath-home-bruno-src","value":"homemaker-layout PYTHONPATH: package installed as 'homemaker-layout' via pip install -e . so 'import homemaker_layout' works from anywhere without PYTHONPATH. For running tests use 'python -m pytest' from project root /home/bruno/src/homemaker-layout (pyproject.toml adds src/ automatically). Never try pip show homemaker — that's the old homemaker-addon conflict."}
|
||||
{"_type":"memory","key":"multi-storey-staircase-consistency-when-dividing-or-retyping","value":"Multi-storey staircase consistency: when dividing or retyping a circulation (C) leaf at one level, the same structural change should be propagated to the matching leaf on ALL other storeys so the stair core path is maintained. The optimizer cannot fix staircase disruptions through trial-and-error geometry alone — it requires a synchronized multi-level operator that applies the same topology change to every storey simultaneously."}
|
||||
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
|
||||
{"_type":"memory","key":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."}
|
||||
{"_type":"memory","key":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."}
|
||||
{"_type":"memory","key":"never-use-corpus-filenames-candidate-001-dom-candidate","value":"Never use corpus filenames (candidate-001.dom, candidate-002.dom, generated.dom, init.dom, etc.) as --output targets when running experiments. These are test fixtures. Always write experimental outputs to scratch/ or a timestamped path. Lesson from 2026-06-14: warm-start runs overwrote candidate-001/002.dom and broke graph tests."}
|
||||
{"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."}
|
||||
{"_type":"memory","key":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."}
|
||||
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
|
||||
{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."}
|
||||
{"_type":"memory","key":"warm-x0-initialization-bug-pattern-when-a-topology","value":"warm_x0 initialization bug pattern: when a topology operator explicitly sets division ratios on a newly-created node (e.g. compound_fix sets node.division=[0.25,0.25] for t3), parent.ratios has no entry for that node (it was a leaf). warm_x0 defaults it to 0.5, corrupting the inner loop's starting point and making the operator invisible to lex comparison. Fix: only propagate child ratios for nodes where the parent node was NOT already divided; stale hidden nodes revealed by structural mutations (swap flipping b.below) must NOT contribute their pre-writeback values. See driver.py lines 259-267 (fixed 2026-06-14)."}
|
||||
{"_type":"memory","key":"deceptive-valleys-in-topology-search-when-every-single","value":"Deceptive valleys in topology search: when every single-step mutation from a target state passes through a high-fail intermediary (e.g. level_fix displaces a room into 5+ new fails), a compound operator that atomically applies two coordinated changes can escape. Design compound operators to land on the low-fail state directly, bypassing the deceptive gradient. Programme-house example: level_compound_fix atomically moves the level-constrained room AND re-inserts the displaced room adjacent to C in one step (operators.py, 2026-06-14)."}
|
||||
{"_type":"memory","key":"never-use-corpus-filenames-candidate-001-dom-candidate","value":"Never use corpus filenames (candidate-001.dom, candidate-002.dom, generated.dom, init.dom, etc.) as --output targets when running experiments. These are test fixtures. Always write experimental outputs to scratch/ or a timestamped path. Lesson from 2026-06-14: warm-start runs overwrote candidate-001/002.dom and broke graph tests."}
|
||||
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."}
|
||||
{"_type":"memory","key":"programme-house-optimisation-result-2026-06-14-15","value":"Programme-house optimisation result (2026-06-14/15): best achievable is 1 fail (l1 wrong level, score ~0.005). 0 fails is geometrically impossible: l1 (min 27m²) must occupy ll (~23m²) at level 0, which eliminates the t3-adj-C provider; dividing ll into lll(l1)+llr(C) gives llr proportion ~6:1 (fails). Python memetic optimizer achieves 1 fail in 50k evals vs Perl optimiser's 2-3 fails. Winning topology: TWO C nodes at level 0 — ll(C) for t3-adj-C via geometric contact, rl(C) for staircase via tree-sibling adjacency to rrr(O). Best .dom: scratch/from-warmstart-fixed.dom and scratch/from-compound3-fixed.dom."}
|
||||
{"_type":"memory","key":"adjacency-in-binary-slicing-tree-is-structural-not","value":"Adjacency in binary slicing tree is structural, not geometric: the inner-loop NM cannot fix topological adjacency failures. Two paths exist: (1) tree-sibling adjacency — a node is adjacent to its sibling in the tree; (2) cross-zone geometric adjacency — leaves from different subtrees that happen to share a boundary. Staircase/adjacency fails require a topology mutation that changes which nodes are siblings or which zones touch. This was proved empirically on programme-house: staircase fail from rot=0 layout could not be fixed by NM but was fixed by level_retype creating a two-C topology (2026-06-14/15)."}
|
||||
{"_type":"memory","key":"cli-tool-style-prefer-python-m-homemaker-module","value":"CLI tool style: prefer python -m homemaker.module --parameters pattern, installable via pip install -e . with pyproject.toml entry_points. Not standalone bin/ scripts."}
|
||||
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
|
||||
|
|
|
|||
74
DESIGN.md
74
DESIGN.md
|
|
@ -869,18 +869,72 @@ stages composed from the existing `driver.search`:
|
|||
and the floors now assembled in dependency order, the lever left is navigation
|
||||
*within* the high-fail plateau, where lex-by-count gives near-zero gradient.
|
||||
|
||||
### 11.4 Graded high-fail objective (`homemaker-py-c4c.4`)
|
||||
### 11.4 Graded high-fail objective (`homemaker-py-c4c.4`) — DONE (negative)
|
||||
|
||||
*Stub.* Extends Phase 4 (§4.9). Lexicographic-by-total-count gives ~zero signal
|
||||
when every candidate sits at ~49–74 fails. Add partial credit (proximity per
|
||||
unsatisfied constraint and/or count of *distinct* unsatisfied requirements) as a
|
||||
secondary key beneath fail-count, preserving the inner-loop cliff (§5.4) and the
|
||||
missing-space hierarchy (§6).
|
||||
Premise (from Phase 4, §4.9): lexicographic-by-total-count `(-n_fails, fitness)`
|
||||
gives ~zero selection signal in the high-fail regime because the `0.5^n` cliff
|
||||
flattens fitness to ~machine-epsilon, so neighbours at ~49–105 fails look
|
||||
indistinguishable. Proposed fix: a continuous proximity key *beneath* fail-count
|
||||
and *above* fitness — `(-n_fails, grade, fitness)`.
|
||||
|
||||
- *Gate:* measured escape from a high-fail plateau the current lex comparator
|
||||
cannot escape at equal budget; inner-loop 0/9-regression check (§4.9) still
|
||||
clean.
|
||||
- *Result:* TODO.
|
||||
**Implementation (kept, default-off).** `fitness._leaf_grade` reads each *failing*
|
||||
per-leaf quality factor (perpendicular/proportion/size/width/crinkliness/access)
|
||||
as proximity-to-satisfaction `f / FAIL_THRESHOLD ∈ [0,1)` and sums it;
|
||||
`Fitness.score_with_grade` returns it alongside score/fails. The scalar fitness
|
||||
and the fail count are **untouched**, so the inner-loop `0.5^n` cliff (§5.4) is
|
||||
unaffected — **inner-loop 0/9-regression check: PASS** (re-ran §4.9 part 1,
|
||||
`run_inner_loop_protection`, 0/9 regressions). The grade is read once per child
|
||||
off the already-optimised tree in `driver._evaluate` (one extra native eval,
|
||||
~1/child_budget) and used **only** in the outer comparator key, behind
|
||||
`search(..., use_grade=True)` / `search_staged(..., use_grade=True)` (default
|
||||
`False`; threaded to Stage 2 only — Stage 1 keeps its readiness key, §11.3).
|
||||
Structural fails (missing/adjacency/edge-too-long/level/…) score 0 grade, so the
|
||||
missing-space hierarchy (§6) is preserved: grade can never reward dropping a room.
|
||||
|
||||
- *Commands (reproduce, `URB_NO_OCCLUSION=1`, 20000 evals):*
|
||||
```bash
|
||||
USE_GRADE=0 python3 experiments/run_staged_search.py examples/harbor-house 20000 <seed> \
|
||||
examples/harbor-house/init.dom scratch/st_lex.dom # lex baseline
|
||||
USE_GRADE=1 python3 experiments/run_staged_search.py examples/harbor-house 20000 <seed> \
|
||||
examples/harbor-house/init.dom scratch/st_grade.dom # lex + grade
|
||||
```
|
||||
- *Result (harbor-house, staged, 20000 native evals, total fails at budget):*
|
||||
|
||||
| seed | staged `lex` | staged `lex+grade` |
|
||||
|-----:|-------------:|-------------------:|
|
||||
| 0 | **95** | 99 |
|
||||
| 1 | **96** | 98 |
|
||||
| 2 | 106 | **102** |
|
||||
| mean | **99.0** | 99.7 |
|
||||
|
||||
Grade wins 1/3 seeds, loses 2/3, and is **slightly worse on the mean** —
|
||||
within seed-noise, **no escape** from the plateau. Single-stage seed 0 is a
|
||||
dead heat (105 = 105). Stage-1 is identical by construction (grade off there);
|
||||
the divergence is entirely in Stage 2, where the grade run **stalls early**
|
||||
(seed 0: last improvement at 13600/20000 evals, stuck at 99) while lex keeps
|
||||
reducing the count (99→95).
|
||||
|
||||
- *Why it fails — the premise is falsified by measurement.* The cliff is constant
|
||||
*within* a fail-tier (`0.5^n`, `n` fixed), so within a tier reported fitness is
|
||||
`value/cost × const` and still spans **~6 orders of magnitude** (seed-0 Stage-2
|
||||
history: 1.2e-37 → 4.6e-31 *all inside the same descending fail count*). The
|
||||
outer comparator only ever compares within a tier (−`n_fails` dominates across
|
||||
tiers), so lex's secondary `fitness` key already carries a strong, well-graded
|
||||
signal — exactly the gradient §11.4 assumed was missing. Inserting `grade`
|
||||
*above* `fitness` **displaces** that working signal: the population fills with
|
||||
high-grade (shallow-fail) incumbents and the fail-reducing restructurings — which
|
||||
transiently deepen other fails and so look worse on grade — are no longer
|
||||
selected. Placing `grade` *below* `fitness` instead would be near-inert (fitness
|
||||
ties are measure-zero in a continuous objective). Either way there is no lever:
|
||||
the high-fail plateau is a *topology* basin, not a comparator-resolution problem.
|
||||
|
||||
- *Verdict: reject the graded objective; lexicographic `(-n_fails, fitness)`
|
||||
stands.* The §11.3 staged **95-fail** result remains the harbor best. The
|
||||
remaining load is genuinely structural (escaping topology basins), which is what
|
||||
**§11.5 (structural niching + restarts)** and the `9gp` canonical-encoding
|
||||
capstone target — not outer-comparator reshaping. The `use_grade` flag and
|
||||
`score_with_grade` are kept default-off for reproducibility and possible reuse
|
||||
(e.g. as a *diversity* signal under §11.5 rather than a selection key).
|
||||
|
||||
### 11.5 Topology diversity: structural niching + restarts (`homemaker-py-c4c.5`)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ Phase-2 reference bars (URB_NO_OCCLUSION=1, budget=2000, native fitness):
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
|
@ -65,10 +66,13 @@ def main() -> int:
|
|||
print(f"ERROR: no .dom seed found in {programme_dir}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
use_grade = os.environ.get("USE_GRADE") == "1" # §11.4 graded objective A/B
|
||||
|
||||
print(f"programme : {programme_dir.name}")
|
||||
print(f"seed : {seed_file.name}")
|
||||
print(f"budget : {budget} native evals")
|
||||
print(f"rng seed : {rng_seed}")
|
||||
print(f"use_grade : {use_grade}")
|
||||
print(flush=True)
|
||||
|
||||
seed_root = dom.load(str(seed_file))
|
||||
|
|
@ -84,6 +88,7 @@ def main() -> int:
|
|||
p_crossover=0.2,
|
||||
seed=rng_seed,
|
||||
log=lambda m: print(m, flush=True),
|
||||
use_grade=use_grade,
|
||||
# urb_root not needed: use_native=True is the default
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ Defaults: harbor-house, budget=20000, rng_seed=0, init.dom seed.
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
|
@ -50,10 +51,13 @@ def main() -> int:
|
|||
print(f"ERROR: no seed .dom at {seed_file}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
use_grade = os.environ.get("USE_GRADE") == "1" # §11.4 graded objective A/B
|
||||
|
||||
print(f"programme : {programme_dir.name}")
|
||||
print(f"seed : {seed_file.name}")
|
||||
print(f"budget : {budget} native evals (staged)")
|
||||
print(f"rng seed : {rng_seed}")
|
||||
print(f"use_grade : {use_grade}")
|
||||
print(flush=True)
|
||||
|
||||
seed_root = dom.load(str(seed_file))
|
||||
|
|
@ -71,6 +75,7 @@ def main() -> int:
|
|||
p_crossover=0.2,
|
||||
seed=rng_seed,
|
||||
log=lambda m: print(m, flush=True),
|
||||
use_grade=use_grade,
|
||||
)
|
||||
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
|
|
|||
|
|
@ -28,15 +28,28 @@ prevent stale id-keyed entries inherited from the parent process.
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import functools
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import dom, innerloop, operators, programme
|
||||
from . import dom, fitness, innerloop, operators, programme
|
||||
|
||||
_CHILD_INNER_KW: dict = {}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _fitness_for(programme_dir: str) -> "fitness.Fitness":
|
||||
"""Cached Fitness evaluator per programme dir (config load is the cost).
|
||||
|
||||
Used only to read the graded proximity scalar (§11.4) off an already-
|
||||
optimised tree in :func:`_evaluate`; the inner loop's own NativeEvaluator is
|
||||
untouched. Cached per process — workers fork their own copy.
|
||||
"""
|
||||
conf, cost = fitness.load_config(programme_dir)
|
||||
return fitness.Fitness(conf, cost)
|
||||
|
||||
# storey add/delete are drastic (geometry perturbation 0.25-0.33 and a
|
||||
# deleted storey stacks missing-space failures) — sample them rarely.
|
||||
# place_missing is the high-leverage §11.2 repair: it noops cheaply once the
|
||||
|
|
@ -63,6 +76,7 @@ class Individual:
|
|||
n_fails: int
|
||||
ratios: dict[tuple[int, str], float]
|
||||
lineage: str = "seed"
|
||||
grade: float = 0.0 # §11.4 graded proximity; secondary comparator key only
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -91,11 +105,20 @@ def random_topology(seed_root: dom.Node, n_leaves: int,
|
|||
|
||||
|
||||
def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
|
||||
lineage: str) -> tuple[Individual, int]:
|
||||
lineage: str, want_grade: bool = False) -> tuple[Individual, int]:
|
||||
r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget,
|
||||
urb_root=urb_root, **inner_kw)
|
||||
# §11.4: read the graded proximity scalar off the optimised tree. The inner
|
||||
# loop left ``root`` at the optimum (Lamarckian write-back), so re-scoring a
|
||||
# copy reproduces r.fitness/r.n_fails exactly and adds the grade. One extra
|
||||
# native eval per child (~1/child_budget overhead); skipped unless requested.
|
||||
grade = 0.0
|
||||
if want_grade:
|
||||
_, _, grade = _fitness_for(str(programme_dir)).score_with_grade(
|
||||
copy.deepcopy(root))
|
||||
ind = Individual(root=root, fitness=r.fitness, n_fails=r.n_fails,
|
||||
ratios=innerloop.ratio_map(root), lineage=lineage)
|
||||
ratios=innerloop.ratio_map(root), lineage=lineage,
|
||||
grade=grade)
|
||||
return ind, r.n_evals
|
||||
|
||||
|
||||
|
|
@ -125,6 +148,7 @@ def search(
|
|||
rank_bonus_weight: float = 1.0,
|
||||
seed_factory=None,
|
||||
base_p: float = 1.0,
|
||||
use_grade: bool = False,
|
||||
) -> SearchResult:
|
||||
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
|
||||
evaluations are consumed. Returns the best individual found; its ``root``
|
||||
|
|
@ -158,8 +182,22 @@ def search(
|
|||
return ind.fitness
|
||||
return ind.fitness * (1.0 + rank_bonus_weight * rank_bonus_fn(ind.root))
|
||||
|
||||
_key = ((lambda ind: (-ind.n_fails, _rank_fitness(ind))) if use_lex
|
||||
else (lambda ind: _rank_fitness(ind)))
|
||||
# §11.4 graded objective (EXPERIMENT, default off — REJECTED, see DESIGN.md
|
||||
# §11.4): a continuous proximity bonus (ind.grade) inserted as a secondary key
|
||||
# BENEATH fail-count and ABOVE fitness, ordering neighbours by how close their
|
||||
# failing constraints are to satisfaction. Hypothesis was that fitness is
|
||||
# ~flat (0.5^n) in the high-fail regime; this was FALSIFIED — within a fixed
|
||||
# fail-tier 0.5^n is constant so fitness still spans ~6 orders of magnitude,
|
||||
# and grade above it merely displaces that working signal (no plateau escape).
|
||||
# Kept default-off for reproducibility. Strictly beneath -n_fails ⇒ the
|
||||
# missing-space hierarchy (§6) is preserved and the inner-loop cliff (§5.4)
|
||||
# is untouched.
|
||||
if use_lex and use_grade:
|
||||
_key = lambda ind: (-ind.n_fails, ind.grade, _rank_fitness(ind))
|
||||
elif use_lex:
|
||||
_key = lambda ind: (-ind.n_fails, _rank_fitness(ind))
|
||||
else:
|
||||
_key = lambda ind: _rank_fitness(ind)
|
||||
# Always load reqs so bootstrap_n_leaves can be auto-derived from programme.
|
||||
reqs = programme.load_programme_dir(programme_dir)
|
||||
if types is None:
|
||||
|
|
@ -213,7 +251,7 @@ def search(
|
|||
"""Evaluate a batch of tasks and admit results; parallel when _pool set."""
|
||||
nonlocal n_evals
|
||||
full = [
|
||||
(root, programme_dir, urb_root, x0, budget_, kw_, lin)
|
||||
(root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade)
|
||||
for root, x0, budget_, kw_, lin in tasks
|
||||
]
|
||||
if _pool is not None:
|
||||
|
|
@ -262,7 +300,8 @@ def search(
|
|||
else:
|
||||
seed_ind, used = _evaluate(copy.deepcopy(seed_root), programme_dir, urb_root,
|
||||
x0=None, budget=seed_budget,
|
||||
inner_kw={}, lineage="seed")
|
||||
inner_kw={}, lineage="seed",
|
||||
want_grade=use_grade)
|
||||
n_evals += used
|
||||
admit(seed_ind, pop)
|
||||
|
||||
|
|
@ -332,6 +371,7 @@ def search_staged(
|
|||
inner_kw: dict | None = None,
|
||||
log=None,
|
||||
n_workers: int = 1,
|
||||
use_grade: bool = False,
|
||||
) -> SearchResult:
|
||||
"""Staged per-floor topology search (DESIGN.md §11.3, ``homemaker-py-c4c.3``).
|
||||
|
||||
|
|
@ -367,7 +407,8 @@ def search_staged(
|
|||
return search(seed_root, programme_dir, budget=budget, pop_size=pop_size,
|
||||
child_budget=child_budget, seed_budget=seed_budget,
|
||||
p_crossover=p_crossover, seed=seed, types=types,
|
||||
inner_kw=inner_kw, log=log, n_workers=n_workers)
|
||||
inner_kw=inner_kw, log=log, n_workers=n_workers,
|
||||
use_grade=use_grade)
|
||||
|
||||
if types is None:
|
||||
types = sorted(reqs) + ["C", "O"]
|
||||
|
|
@ -411,6 +452,10 @@ def search_staged(
|
|||
p_crossover=p_crossover, seed=seed, types=types,
|
||||
inner_kw=inner_kw, log=log, n_workers=n_workers,
|
||||
bootstrap=True, seed_factory=_seed_factory, base_p=base_p,
|
||||
# §11.4: the graded objective targets the dense two-floor quality-fail
|
||||
# regime, which is Stage 2. Stage 1 keeps its readiness-biased key so the
|
||||
# substrate-selection semantics (§11.3) are unchanged.
|
||||
use_grade=use_grade,
|
||||
)
|
||||
|
||||
# Stitch the two stages into one accounting (total evals, tagged history).
|
||||
|
|
|
|||
|
|
@ -32,6 +32,36 @@ from .dom import Node
|
|||
|
||||
FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base
|
||||
|
||||
# Per-leaf quality factors that emit a failure when they drop below
|
||||
# FAIL_THRESHOLD (evaluate_leaf, in emission order). The graded objective
|
||||
# (DESIGN.md §11.4) reads each failing factor's value as a continuous proximity
|
||||
# to satisfaction — it does NOT change the scalar fitness or the fail count, only
|
||||
# supplies a tie/secondary signal to the outer comparator (driver.py).
|
||||
_GRADED_FACTORS = ("perpendicular", "proportion", "size", "width",
|
||||
"crinkliness", "access")
|
||||
|
||||
|
||||
def _leaf_grade(factors: dict[str, float]) -> float:
|
||||
"""Proximity credit for one leaf's *failing* quality factors.
|
||||
|
||||
Each factor below FAIL_THRESHOLD contributes ``f / FAIL_THRESHOLD`` ∈ [0, 1):
|
||||
deeper failures score ~0, near-threshold failures score ~1. Summing this over
|
||||
all failing factors gives a continuous proximity signal. Passing factors
|
||||
contribute nothing — the signal lives entirely in the failing set — and
|
||||
structural/binary fails (missing, adjacency, edge-too-long, …) contribute 0,
|
||||
so the measure can never reward dropping a required room (§6 preserved).
|
||||
|
||||
Intended as an outer-comparator secondary key, but REJECTED as such (DESIGN.md
|
||||
§11.4): within a fixed fail-tier the scalar fitness is not flat, so this added
|
||||
no benefit. Kept for reproducibility / possible reuse as a diversity signal.
|
||||
"""
|
||||
g = 0.0
|
||||
for name in _GRADED_FACTORS:
|
||||
fv = factors.get(name, 1.0)
|
||||
if fv < FAIL_THRESHOLD:
|
||||
g += fv / FAIL_THRESHOLD
|
||||
return g
|
||||
|
||||
# Urb::Dom::Fitness::Base $CONF — keep values byte-identical to the Perl
|
||||
# expressions (5.0/6 etc. evaluate to the same IEEE doubles in both languages).
|
||||
CONF_DEFAULTS: dict = {
|
||||
|
|
@ -953,14 +983,28 @@ class Fitness:
|
|||
|
||||
Returns ``value / cost`` (the final score as in Urb).
|
||||
"""
|
||||
score, _ = self._evaluate_full(root)
|
||||
score, _, _ = self._evaluate_full(root)
|
||||
return score
|
||||
|
||||
def score_with_fails(self, root: Node) -> tuple[float, tuple[str, ...]]:
|
||||
"""Same as ``evaluate`` but also returns the sorted failure strings."""
|
||||
return self._evaluate_full(root)
|
||||
score, fails, _ = self._evaluate_full(root)
|
||||
return score, fails
|
||||
|
||||
def _evaluate_full(self, root: Node) -> tuple[float, tuple[str, ...]]:
|
||||
def score_with_grade(
|
||||
self, root: Node
|
||||
) -> tuple[float, tuple[str, ...], float]:
|
||||
"""``score_with_fails`` plus the graded proximity scalar (§11.4).
|
||||
|
||||
The grade is a continuous secondary signal for the outer comparator only;
|
||||
it leaves ``score`` and the fail count untouched (and so the inner-loop
|
||||
0.5^n cliff protection, §5.4, intact).
|
||||
"""
|
||||
return self._evaluate_full(root, want_grade=True)
|
||||
|
||||
def _evaluate_full(
|
||||
self, root: Node, want_grade: bool = False
|
||||
) -> tuple[float, tuple[str, ...], float]:
|
||||
from . import graph as graph_mod
|
||||
|
||||
geometry.clear_cache()
|
||||
|
|
@ -1005,6 +1049,7 @@ class Fitness:
|
|||
|
||||
cost = self.plot_cost(root)
|
||||
value = 0.0
|
||||
grade = 0.0
|
||||
lvls = dom_mod.levels(root)
|
||||
|
||||
for li, lvl in enumerate(lvls):
|
||||
|
|
@ -1017,6 +1062,9 @@ class Fitness:
|
|||
)
|
||||
cost += se.cost
|
||||
value += se.value
|
||||
if want_grade: # §11.4 outer-comparator signal only; off by default
|
||||
for le in se.leaves:
|
||||
grade += _leaf_grade(le.factors)
|
||||
|
||||
building_factor = self.evaluate_building(root, tracking)
|
||||
value *= building_factor
|
||||
|
|
@ -1025,7 +1073,7 @@ class Fitness:
|
|||
value *= 0.5 ** len(failures)
|
||||
|
||||
score = value / cost if cost != 0.0 else 0.0
|
||||
return score, tuple(sorted(failures))
|
||||
return score, tuple(sorted(failures)), grade
|
||||
|
||||
@property
|
||||
def _programme(self) -> dict | None:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ import pytest
|
|||
|
||||
from homemaker_layout import dom, geometry
|
||||
from homemaker_layout.dom import Node
|
||||
from homemaker_layout.fitness import CONF_DEFAULTS, COST_DEFAULTS, Fitness, gaussian
|
||||
from homemaker_layout.fitness import (
|
||||
CONF_DEFAULTS,
|
||||
COST_DEFAULTS,
|
||||
FAIL_THRESHOLD,
|
||||
Fitness,
|
||||
_leaf_grade,
|
||||
gaussian,
|
||||
)
|
||||
|
||||
|
||||
def _leaf(type_: str, size: float = 4.0) -> Node:
|
||||
|
|
@ -234,3 +241,36 @@ def test_ideal_going_above_minimum():
|
|||
result = Fitness._ideal_going(0.15)
|
||||
assert result >= 0.22
|
||||
assert result <= 0.625
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Graded high-fail objective (§11.4)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_leaf_grade_no_failing_factors_is_zero():
|
||||
# All factors above FAIL_THRESHOLD → no proximity credit.
|
||||
assert _leaf_grade({"size": 0.9, "width": 1.0, "access": 1.0}) == 0.0
|
||||
|
||||
|
||||
def test_leaf_grade_credits_only_failing_factors():
|
||||
# Only size fails (0.05 < 0.1); credit = 0.05 / 0.1 = 0.5.
|
||||
g = _leaf_grade({"size": 0.05, "width": 0.5, "proportion": 1.0})
|
||||
assert g == pytest.approx(0.05 / FAIL_THRESHOLD)
|
||||
|
||||
|
||||
def test_leaf_grade_monotone_in_proximity():
|
||||
# A failing factor closer to the threshold scores higher (better).
|
||||
deep = _leaf_grade({"size": 0.01})
|
||||
shallow = _leaf_grade({"size": 0.09})
|
||||
assert shallow > deep
|
||||
|
||||
|
||||
def test_leaf_grade_sums_over_failing_factors():
|
||||
g = _leaf_grade({"size": 0.04, "width": 0.06, "access": 1.0})
|
||||
assert g == pytest.approx((0.04 + 0.06) / FAIL_THRESHOLD)
|
||||
|
||||
|
||||
def test_leaf_grade_ignores_non_graded_keys():
|
||||
# daylight is pinned and never a graded factor even if below threshold.
|
||||
assert _leaf_grade({"daylight": 0.0}) == 0.0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue