Compare commits

...

10 commits

Author SHA1 Message Date
0d4ae7ad54 Add missing test files 2026-08-28 09:46:11 +01:00
Claude
bb27a5e548
ssz: 61% of reported crinkliness fails are not defects; two corrections
Correction 1: 38.8's A/B scores every arm under stock urb, which is only
sound if stock is ground truth -- and stock is what this section shows is
miscalibrated. Scoring the repair under the objective it repairs penalises
it for repairing. That result is not evidence against usage_daylight.

The measurement that matters needs no search: are the emitted failures
true? audit_crinkliness_truth.py classifies every stock crinkliness fail by
the leaf's declared usage. 164 of 271 (61%) are reported against spaces
that want no daylight -- stores, WCs, plant, corridors, covered courtyards
-- and it holds on evolved artefacts (maple generated.dom: 64%), not just
constructed seeds. Under value *= 0.5 ** len(failures) each one halves the
fitness of a design that did nothing wrong.

Correction 2: usage: is the wrong key, and usage_daylight as written
mis-keys it. 39.7 established usage: as an ACCESS-requirement class.
"Needs no special access" and "needs no window" are different questions,
and the corpus separates them: usage: none is Waiting Room, Reception,
Reception Office, Entrance Foyer -- a waiting room plainly wants daylight
and DAYLIGHT_USAGES exempts it. The usage: bedroom half is right, but by
luck of where the access axis happened to fall.

The audit is robust to that error (61% -> 57% if none is reclassified), so
the finding stands; the design does not. Daylight needs its own declared
per-space axis, decided by the programme author as usage: was.

usage_daylight stays default off and is not the shipping fix. The
compact-side clip is the right shape for the factor, so the mechanism is
retained pending a daylight: attribute to key it on.

Higher-power A/B stopped: it was chasing a score against a flawed objective
with a mis-keyed fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 18:07:01 +00:00
Claude
6405ca1a59
ssz: record the A/B result honestly -- not a pass at n=3
Fixed-budget A/B (3000 evals, 3 seeds, harbor + maple, every arm scored
under stock urb so the permissive modes cannot win by deleting a fail
category).

usage_daylight's paired hard-fail deltas are harbor [0,-1,-10] and maple
[0,+2,-10]. The means (-3.7, -2.7) flatter it: the whole signal is seed 2
in both programmes, and seed 2 is the seed where stock itself does worst.
Two seeds in three are flat or slightly worse. On that seed soft rises as
much as hard falls (harbor -10h/+9s, maple -10h/+15s), so totals go
62->61 on harbor and 120->125 on maple.

Because the scoring is stock, that is a genuine trade of hard failures for
soft ones, not a relabelling -- progress under the tiered comparator, where
n_hard is primary, but a fail against this issue's acceptance criterion
("without inflating soft"). Which yardstick is right is now the live
question, and it is gvb's question as much as ssz's.

usage_daylight stays default off; undecided, not refuted. Higher-power run
(urb vs usage_daylight, 10 seeds) is running.

The diagnostic half stands independent of the search A/B: the objective
demands daylight of two thirds of the buried population that does not want
it, and 38.6's contrary null was an artefact of three modes that never
touched those leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 17:57:11 +00:00
Claude
f1831687c1
ssz A/B: results snapshot (6/30 arms; run in progress)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 16:50:28 +00:00
Claude
02967d5de7
ssz: use the module-level programme import in needs_daylight
fitness.py already imports the module as `_programme` at the top and uses
that idiom elsewhere (`_programme.SOCIABLE_USAGES`). The local re-import
sat in the per-leaf hot path for no reason.

Also snapshots the in-progress ab_ssz_search.csv; the full run is still
going and will supersede it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 16:46:59 +00:00
Claude
189efdbfc7
ssz: daylight is required of rooms that do not need it
DESIGN.md 38.6 concluded the three crinkliness modes were inert against the
circulation-deletion incentive. Two things were wrong with that measurement.
Its premise, 38.2, is retracted. And its script selected leaves with the
pre-39.4 prefix rule `type[:1].upper() in ("C","O")`, which sweeps every
programme room starting with c or o -- cr1, of1 -- in as circulation.

The simpler problem is that none of the three modes ever touched the leaves
ssz is about. quality_uncrinkliness reaches `if not crink` before any mode
logic that matters, so for a zero-exposure leaf: floor returns 0.01 (one
percent of a unit quality, multiplied into a product and weighed against a
whole leaf's cost -- inert); compact_ok is self-contradictory, announcing
that compact is not a defect and then returning the floor for the most
compact case of all; exempt_circulation reaches at most a third of them.
Measured: 0% / 0% / 0% / 21-33% of buried leaves rescued.

What the buried leaves are, now that 39.7 gives every space a usage: two
thirds of them are spaces that architecturally do not want a window --
stores, WCs, plant, corridors, covered courtyards -- scored identically
with a windowless bedroom. harbor 22/33, maple 33/46, health 9/18.

  - crinkliness_mode="usage_daylight": daylight required of the uses a
    person occupies (programme.DAYLIGHT_USAGES) and nothing else. Elsewhere
    the factor is clipped on the compact side only, so being buried stops
    being a defect while over-exposure still costs -- a crinkly leaf costs
    envelope whatever it is used for. A windowless bedroom stays the hard
    zero it is under stock: 11/11, 13/13, 9/9 still failing.
  - compact_ok repaired to score the buried limit as compact, the behaviour
    its name always claimed. It now rescues 100% including bedrooms, and is
    kept as the upper-bound control, not a candidate.
  - ab_ssz_search.py: the fixed-budget search A/B ssz's acceptance criteria
    actually asks for. Every arm is optimised under its own objective and
    re-scored under stock urb, because the permissive modes return 1.0
    where stock fails and would otherwise win by deleting a fail category.
  - ab_crinkliness_mode_ssz.py: prefix rule fixed, retracted premise
    flagged in its docstring.
  - 38.7's remaining claims from the retracted 38.2/38.3 corrected.

Default is unchanged ("urb"), byte-identical to all prior runs. Lint at
parity (46 pre-existing); tests 366 passed, 10 new, same 7 pre-existing
fixture failures (homemaker-py-bdf).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 16:45:10 +00:00
Claude
8522576467
Frontage feasibility as a pre-flight check; retract the "3x short" claim
Closes homemaker-py-tdp. The bound it was built on (every interior leaf
needs L >= A/(1.6202*h) of daylit wall) is sound, but tdp applied it to a
FULLY BUILT plot. None of these programmes ask for a fully built plot.

Recomputed against the area each programme actually demands, harbor-house
and maple-court are not frontage-infeasible: they need 49 m2 and 22 m2 of
courtyard against 277 m2 and 424 m2 of spare plot. The "2.7x / 2.9x short"
figures are withdrawn, and with them the claim that the plateau programmes
are unsatisfiable as specified -- the plateau remains unexplained.

One corpus programme is genuinely infeasible, for a much cruder reason:
health-centre demands 240 m2 of floor on a 183 m2 plot (131%), single
storey. Every room lands at 0.60x its declared target, 100% undersized,
uniformly. Filed as homemaker-py-7b7, blocking homemaker-py-7xb.

  - evolve._preflight: two closed-form checks at startup (does the demand
    fit the plot; is there enough daylit wall for it). Advisory only, it
    never blocks a run -- an author may be exploring an over-tight brief
    deliberately. Silent on programme-house.
  - diag_exposure_frontage.frontage_budget reports the full budget.
  - DESIGN.md 39.11 with the corrected corpus table; 38.3 marked PARTLY
    RETRACTED and cross-referenced.

Both measure plot area and frontage through geometry rather than the raw
init.dom corners, so they carry the wall_outer inset and plot rotation,
and "daylit" means what Fitness.area_outside means by it. A hand-rolled
first version skipped the inset and read ~1 m / ~14 m2 optimistic per
plot; 39.11 carries the corrected numbers.

Lint unchanged at 46 pre-existing findings; tests unchanged at 7
pre-existing failures (the uncommitted evolved-3M*.dom fixtures,
homemaker-py-bdf).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 16:20:52 +00:00
Claude
024311a2c2
§39.10: preserving constructed connectivity is NULL — and it reframes §39.9
§39.9 named the upstream fix: keep circulation connected DURING the resize
rather than rebuilding it after. Built and measured. It does not help, and the
reason matters more than the lever.

Both halves of the re-cut do damage, in different proportions per programme.
Freezing rotations and letting only ratios move (% levels connected, 12 seeds):
harbor 100 -> 71 -> 50, health-centre 100 -> 8 -> 8, maple 100 -> 92 -> 67. So
health-centre is destroyed entirely by the ratio and maple mostly by the
rotation; a fix must be able to give back either.

operators._size_divisions_preserving_circulation snapshots every cut, resizes,
then reverts the cuts on the tree path between each circulation pair the resize
broke -- programme fully intact, no retyping, only geometry given back. It works
on connectivity (harbor 50->92%, maple 67->97%, health-centre 8->17%) and costs
area accuracy: constructed-seed fails harbor 96.6->141.5, maple 141.8->175.8,
size fails roughly double. (A greedy single-cut revert barely moved -- it stalls
where no ONE revert helps though two would. Targeting the broken pairs is what
made connectivity work.)

The obvious defence -- raw constructed seeds understate it, the resize is only a
warm start, the inner loop should recover -- was TESTED AND FAILS. Full search,
harbor-house, 12000 evals, seed 1:
  OFF  43 fails,  9 hard, 3 connectivity
  ON   65 fails, 26 hard, 4 connectivity
Worse on every axis, including connectivity itself.

REFRAMING: §39.9's fact stands (the resize destroys 41 of 49 circulation edges)
but is NOT ACTIONABLE, because construction-time connectivity does not determine
final connectivity. The search discards and rebuilds the seeder's circulation
either way, and constraining the seed only spends area quality the search cannot
recover. Together with §39.8 (not an incentive problem) that retires the framing
this thread inherited from §38: connectivity is neither a construction problem
nor an incentive one.

Both flags (repair_circulation, preserve_circulation) stay default off with the
numbers recorded, plus byte-identical-default tests. Do not revisit either
without a new formulation -- the standing this document gives bubble.py.

356 passed (+1 new), same 7 pre-existing fixture failures, lint unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 15:11:46 +00:00
Claude
7c41069226
§39.9: level-not-connected is destroyed by the resize, not by the search
Answers homemaker-py-yql. §39.8 established the search is not PAID to sever
circulation; this establishes where connectivity actually goes.

CONSTRUCTED, THEN LOST -- at construction time, in the resize.
_assign_adjacency_aware picks circulation as a CONNECTED dominating set and
succeeds every time. _size_divisions_from_targets then moves every wall to hit
the programme's area targets and destroys it.

Measured over 20 constructed seeds per programme, fully-connected seeds:
harbor-house 1/20, health-centre 1/20, maple-court 0/20. The control -- same
seeds with proportion_aware=False, i.e. no resize -- is 100% connected on all
three. Mechanism confirmed on health-centre: 41 of 49 circulation-to-circulation
edges destroyed by the resize, surviving shared walls squeezed to 0.54-1.11 m
against door_width=1.2, so they stop counting as edges. This is the failure mode
§37.7 recorded for CP-SAT assignment, never looked for in connectivity, where it
costs 35-95 points.

§39.7 COST CHECK: zero. Identical rates under prefix-inferred vs declared
usages -- has_circulation never trims C-C edges, so last commit's usage change
could not and did not make connectivity harder to achieve.

REPAIR MEASURED NEGATIVE. operators.repair_circulation_settled applies §37.7's
own alternating-minimisation fix (re-connect against the settled geometry by
retyping the cheapest bridging leaves to C). It restores 100% connectivity on
all three programmes -- and is still the wrong trade: connectivity fails fall
0.8-1.7 per seed while missing-room fails rise 5.0-8.5, because every retyped
leaf displaces a required room at a 3-5 fail cascade (§38.5). Kept default off
with the write-up, per house style for a null lever, plus a byte-identical
default test and a test asserting it does reconnect every storey.

NEXT LEVER, FILED: preserve the connection during the resize (constrain
_size_divisions_from_targets so a shared C-C boundary cannot fall below
door_width) rather than rebuild it afterwards at the programme's expense --
a constraint on an existing solve, not a new repair pass. solver.py's existing
min_width_generic is the same idea applied to leaf width rather than to a shared
boundary, so it may belong beside it.

Adds experiments/diag_connectivity_yql.py (construct / cost / survive reports).
355 passed (+2 new), same 7 pre-existing fixture failures, lint unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 14:39:07 +00:00
Claude
3aee813ccd
§39.8: homemaker-py-2v1 connectivity weighting — MEASURED NULL, premise retracted
§38.2 concluded the objective is net-positive on severing a level's
circulation: merging a corridor into a habitable sibling gains x6
(value_inside/value_circulation), while "level N not connected" costs x0.5, so
break-even needs 0.5^w < 50/300, w > 2.58 -- "severing must cost at least 3
fails and costs 1". The arithmetic is right. The premise is wrong.

Shipped anyway, EXPERIMENTAL and default off (byte-identical):
fitness.connectivity_weight_for(value_inside, value_circulation) returns the
smallest weight making severing net-negative -- 3.0 at the defaults, DERIVED
from the rates rather than hard-coded so it tracks them if either is retuned.
conf["connectivity_weight"] takes 1.0 / "auto" / a number and counts each
connectivity failure as w failures in the 0.5^n penalty.

MEASUREMENT: at auto (=3) the §38.2 deletion test does not move at all -- 5/25
rewarded either way, median x0.26 vs x0.27. Reason: the connectivity fail count
is UNCHANGED in every rewarded deletion (115->107 fails but 5->5 connectivity;
107->99 but 3->3; 78->71 but 3->3). Weighting a fail that never fires changes
nothing.

And when a deletion DOES break connectivity, it is already punished. Every such
case, 4 seeds per programme: harbor-house 2 of 32 sampled deletions, both
punished (x0.00, x0.01); maple-court 5 of 32, all punished (x0.58 .. x0.07).
Severing costs 1-2 connectivity fails PLUS the cascade after them, which
already outweighs the x6 gain. The flat rule was never the problem.

Where §38.2 went wrong: the x4.06 "well-daylit circulation leaf" that motivated
the bead was a deletion that did NOT change the connectivity fail count. It was
rewarded for removing the leaf's own quality failures -- §38.1's zero-value
finding -- and I misread it as a pricing mechanism. §38.2 now carries the
retraction inline. Two lessons recorded: a plausible closed-form arithmetic is
not a measurement, and when a fix produces exactly no effect, suspect the
premise before the implementation.

Still standing from §38: §38.1 (buried leaves score zero quality and contribute
no value) and §38.3 (frontage budget) are direct measurements. §39.7 remains
the better lever on the same symptom -- it made the connectivity fails FIRE,
where this would only have made them cost more.

Re-opened as homemaker-py-yql: why level-not-connected persists in the best
layout when severing is already punished. Evidence now points at reachability,
not incentive, and it is newly measurable because §39.7 stopped store cupboards
standing in for corridors.

353 passed (+3 new), same 7 pre-existing fixture failures, lint unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2026-08-26 14:15:22 +00:00
18 changed files with 2532 additions and 55 deletions

File diff suppressed because one or more lines are too long

492
DESIGN.md
View file

@ -4929,10 +4929,18 @@ favour, against a measured ×4.06. **The connectivity fail is under-priced by
roughly 3×, so the objective is net-positive on destroying the circulation
spine even when the circulation is perfectly daylit.**
That is the cleanest available explanation of why `level 0 not connected` and
`level 1 not connected` are still present in the best layout found after
1.7 M evals: the search is not failing to fix them, it is being paid ×34 to
create them.
**RETRACTED — see §39.8.** The inference above ("the objective is net-positive
on severing the spine") does not survive measurement. It assumed severing costs
exactly one failure; it does not. Every deletion that actually breaks
connectivity is already punished — measured ×0.00 to ×0.58 across harbor-house
and maple-court, not one rewarded. The ×4.06 figure above is real but was
measured on a deletion that did **not** change the connectivity fail count, so
it is not evidence for this mechanism. The deletions that are rewarded are
rewarded because they remove the deleted leaf's OWN quality failures (79 of
them), which is §38.1's zero-value finding, not a connectivity mispricing.
Why `level 0/1 not connected` persist in the best layout is therefore still
open, but it is not that the search is paid to create them.
Together these retro-explain three prior results as one mechanism, and suggest
two of them were measuring a broken gradient rather than a bad idea:
@ -4960,6 +4968,14 @@ plot perimeter `private`**:
| health-centre | 43 m | 41 m | feasible | §32 clean null | — |
| programme-house | 24 m | 12 m | 2× surplus | — | **1 fail @ 12k evals** |
**PARTLY RETRACTED — see §39.11.** The "2.7× / 2.9× short" figures below are
computed for a **fully built plot**, which is not what these programmes ask
for. Against actual demand the deficits are far smaller and both are closable:
harbor needs 86 m against 54 m supplied (a 48 m² courtyard, with 304 m² of plot
spare), maple 70 m against 56 m (20 m²). Neither is infeasible. The one
programme that genuinely does not fit is health-centre, for an unrelated and
much simpler reason: it demands 240 m² on a 197 m² plot.
**Frontage deficit predicts the COST of solving, not impossibility.** An
earlier draft of this section claimed the deficit predicts the plateau
outright, quoting §13.11's 20k-budget figure as harbor's floor; that was
@ -5059,10 +5075,16 @@ fails to move** — `homemaker-py-2v1` is the half that matters.
rather than unmet") was aimed at the right target, and §38.3 supplies a cheap
way to test it that does **not** need `2g7.1`'s traced human plans: the
frontage bound is a pre-flight feasibility check computable from a plot and a
programme alone. Two of the four corpus programmes fail it by ~3×, which means
a share of the residual those runs are being judged on **is not reachable at
all** — and any A/B measured against that residual has been measuring, in
part, an unsatisfiable constraint.
programme alone.
*(As first written this paragraph continued "two of the four corpus programmes
fail it by ~3×, which means a share of the residual those runs are being judged
on is not reachable at all". **That is withdrawn** — see §39.11. The ~3× came
from applying the bound to a fully built plot rather than to the area each
programme demands; harbor-house and maple-court are frontage-feasible with room
to spare. One programme is unsatisfiable, health-centre, and for a cruder
reason: it demands 131% of its plot. So the residual the other runs are judged
on is reachable, and the plateau is not explained by an unsatisfiable brief.)*
Tracks 2 and 3 (cheaper evaluation, exact sub-solvers) remain sound but are
orthogonal: making an evaluation 97× faster, or a labelling exact, does not
@ -5073,6 +5095,12 @@ dominant mechanism, and the one the §38.6 A/B isolated) → `ssz`/`hxi`/`gvb`
pre-flight bound and re-baseline the corpus), and only then resume
`2g7.9`/`2g7.10`.
*(Both halves of that ordering's rationale have since been measured and did not
survive. `2v1` closed NULL — severing the spine is already punished, §38.2 is
retracted — and `tdp`'s infeasibility claim is retracted above. What the
ordering got right is that `ssz` comes before `2g7.7`; see §38.8 for what `ssz`
turned out to be.)*
**Acceptance test for the combined fix, stated up front so it cannot be
moved:** harbor-house must reach its known 15-fail floor in materially fewer
than 1.7 M evals, *and* `level 0 not connected` / `level 1 not connected` must
@ -5083,6 +5111,180 @@ valley-crossing multi-edit against an objective that pays ×85 to delete the
corridor it just inserted will have its work reverted by the next selection
step.
### 38.8 What `ssz` actually was: the objective demands daylight for rooms that do not need it (`homemaker-py-ssz`)
**First, why §38.6's A/B does not stand.** It measured the three modes against
the §38.2 *deletion test*, and it did so with a script that predates §39.4:
`experiments/ab_crinkliness_mode_ssz.py` selected "unpinned" leaves with
`(leaf.type or "")[:1].upper() in ("C", "O")`, the first-character prefix rule,
so every programme room whose code happens to begin with c or o — `cr1`, `of1`
— was swept in as circulation. Both the premise (§38.2, retracted) and the
selection were wrong. The script is kept, with the prefix rule fixed and a
warning in its docstring, but nothing is decided on its numbers.
**Second, and simpler: none of the three modes ever touched the leaves `ssz` is
about.** `quality_uncrinkliness` reaches `if not crink: return ...` *before* any
of the mode logic that matters, so for a zero-exposure leaf:
| mode | buried leaves | rescued to ≥ `FAIL_THRESHOLD` | windowless habitable rooms still failing |
|---|---|---|---|
| `urb` (stock) | 33 / 46 / 18 | 0% | 11/11, 13/13, 9/9 |
| `floor` | 33 / 46 / 18 | 0% | 11/11, 13/13, 9/9 |
| `compact_ok` *(as measured in §38.6)* | 33 / 46 / 18 | 0% | 11/11, 13/13, 9/9 |
| `exempt_circulation` | 33 / 46 / 18 | 21% / 24% / 33% | 11/11, 13/13, 9/9 |
*(harbor-house / maple-court / health-centre, 3 constructed seeds each, full
default stack.)*
`floor` returns 0.01 — one percent of a unit quality, multiplied into a product
and weighed against a whole leaf's cost, which is why it reads as inert.
`compact_ok` is worse than inert, it is **self-contradictory**: it announces
that being more compact than target is not a defect, and then returns the floor
for the most compact case of all, because `if not crink` fires before its clip
is ever reached. Only `exempt_circulation` moves anything, and it reaches at
most a third of the population. So §38.6's "none of them removes the incentive"
was reading a null that the modes' own implementation guaranteed.
**What the buried leaves actually are.** §39.7 gave every space a declared
`usage:`, which lets the question be asked properly for the first time — of the
leaves scoring a hard zero for want of daylight, how many are rooms a person
sits in?
| programme | buried | habitable (`living`/`kitchen`/`bedroom`) | store, toilet, plant, corridor, covered court |
|---|---|---|---|
| harbor-house | 33 | 11 (33%) | **22 (67%)** |
| maple-court | 46 | 13 (28%) | **33 (72%)** |
| health-centre | 18 | 9 (50%) | 9 (50%) |
**Roughly two thirds of the zero-value leaves are spaces that architecturally
do not want a window at all** — a broom cupboard, a WC, a plant room, an
internal corridor, a covered courtyard. The objective scores them identically
with a windowless bedroom. That is the miscalibration, and it is not a gradient
problem to be patched with an epsilon; it is the wrong requirement applied to
the wrong rooms.
**The repair: `crinkliness_mode="usage_daylight"`.** Daylight is required of
the uses a person occupies (`programme.DAYLIGHT_USAGES` =
`living`/`kitchen`/`bedroom`) and of nothing else. For every other usage, and
for the generic `C`/`O`/`S` types which carry no programme entry, the factor is
clipped on the **compact side only** — being buried stops being a defect, while
over-exposure still costs, because a crinkly leaf costs envelope whatever it is
used for. A windowless bedroom remains exactly the hard zero it is under stock.
| programme | buried | rescued by `usage_daylight` | windowless habitable rooms still failing |
|---|---|---|---|
| harbor-house | 33 | 22 (67%) | 11/11 |
| maple-court | 46 | 33 (72%) | 13/13 |
| health-centre | 18 | 9 (50%) | 9/9 |
`compact_ok` was also repaired to score the buried limit as compact (1.0), the
behaviour its name always claimed; it now rescues 100% and is kept as the
**upper-bound control** — the mode that deletes the daylight requirement
outright, including for bedrooms. It is not a candidate.
**How this is scored, stated before the result.** `compact_ok`,
`exempt_circulation` and `usage_daylight` all return 1.0 where stock returns
below `FAIL_THRESHOLD`, so scoring an arm under its own objective deletes a fail
category for free and every arm "wins". Every arm below is therefore optimised
under its own objective and **re-scored under stock `urb`** — the comparable
yardstick, and the only one that answers *did optimising under this variant
steer the search to a better building?* The arm's own-objective count is
reported alongside solely to show the size of the definitional discount. A mode
passes on the stock column. `experiments/ab_ssz_search.py`.
**The result: NOT A PASS at this budget, and n=3 cannot decide it.** Budget
3000, 3 seeds, paired per-seed deltas against `urb` (negative = fewer fails):
| mode | harbor hard Δ | mean | maple hard Δ | mean |
|---|---|---|---|---|
| `floor` | [0, 1, 2] | 1.0 | [+5, 2, 1] | +0.7 |
| `compact_ok` | [0, 2, **12**] | 4.7 | [0, +2, +1] | +1.0 |
| `exempt_circulation` | [+2, 3, 1] | 0.7 | [0, +2, +1] | +1.0 |
| `usage_daylight` | [0, 1, **10**] | 3.7 | [0, +2, **10**] | 2.7 |
The means flatter every mode. **The whole signal is seed 2**, in both
programmes, and seed 2 is the seed where stock itself does worst (harbor 22
hard against 16/22; maple 51 against 30/23). Two seeds in three are flat or
slightly worse. What this says is "on a bad run, the permissive modes do less
badly" — which is not nothing, but it is not the claim.
And on that seed the soft count rises by as much as the hard count falls:
harbor seed 2 10 hard / +9 soft, maple seed 2 10 hard / +15 soft. **Totals**:
harbor 62 → 61, maple 120 → **125**. Because every arm is scored under stock
`urb`, this is a real change of layout and not a relabelling — the search
genuinely traded hard failures for soft ones. Under the project's tiered
comparator, where `n_hard` is the primary key, that trade is progress. Under
`ssz`'s acceptance criterion — *lowers hard without inflating soft* — it is
not. The criterion is stricter than the comparator the search actually uses;
which of the two is the right yardstick is now the live question, and it is
`homemaker-py-gvb`'s question as much as this one.
`usage_daylight` stays **default off** pending a higher-powered run
(`urb` vs `usage_daylight` only, more seeds, both programmes). Nothing here
justifies shipping it as a default, and nothing here refutes it either: at
n=3 with one dominant seed, the honest reading is *undecided*.
**What is decided** is the diagnostic half, which does not depend on the search
A/B: the daylight requirement is applied to rooms that architecturally do not
want daylight, in two thirds of the buried population, and §38.6's contrary
null was an artefact of three modes that never touched those leaves.
### 38.9 Two corrections to §38.8, and the measurement that matters (`homemaker-py-ssz`)
**Correction 1 — the A/B yardstick above is wrong.** §38.8 scores every arm
under stock `urb`, reasoning that a permissive mode must not be allowed to win
by deleting a fail category. That is sound only if stock is ground truth, and
stock is exactly what this section shows is miscalibrated. Scoring the repair
under the objective it repairs penalises it for repairing: stock counts a
windowless broom cupboard as a failure, and the repair's whole purpose is to
stop counting it. *Does the fix score well on the broken yardstick* is not a
question worth answering, and the §38.8 A/B result should not be read as
evidence against `usage_daylight`.
**The measurement that does matter** asks whether the emitted failures are
*true*, and needs no search at all (`experiments/audit_crinkliness_truth.py`).
Of the `crinkliness` failures the stock objective reports, classified by the
leaf's declared usage:
| layout | crinkliness fails | on spaces that want no daylight |
|---|---|---|
| harbor-house, 3 constructed seeds | 67 | 41 (61%) |
| maple-court, 3 constructed seeds | 112 | 68 (61%) |
| health-centre, 3 constructed seeds | 20 | 10 (50%) |
| harbor-house `generated.dom` (evolved) | 5 | 2 (40%) |
| maple-court `generated.dom` (evolved) | 67 | 43 (64%) |
| **overall** | **271** | **164 (61%)** |
**61% of the crinkliness failures the objective reports are not defects**, and
it holds on evolved artefacts, not just seeds. Under
`value *= 0.5 ** len(failures)` every one of them halves the fitness of a design
that has done nothing wrong — a design is punished for putting the store in the
middle of the plan, which is what a competent architect does. That is a
correctness fault, and it is not contingent on any A/B.
**Correction 2 — `usage:` is the wrong key, and `usage_daylight` as written in
§38.8 mis-keys it.** §39.7 established `usage:` as an *access-requirement*
class. "Needs no special access" and "needs no window" are different questions,
and the corpus separates them plainly:
- `usage: none` is **Waiting Room**, **Reception**, **Reception Office**,
**Entrance Foyer** — a waiting room is a space people sit in for long
stretches and plainly wants daylight, yet `DAYLIGHT_USAGES` exempts it;
- `usage: bedroom` is where the GP consulting rooms, counselling rooms and
staff offices live — all of which do want daylight, so that half is right,
but it is right by luck of how the access axis happened to fall.
The audit is robust to the error (reclassifying `none` as wanting daylight
moves the headline 61% → 57%), so the finding stands; the *design* does not.
Daylight needs its own declared axis, per space, decided by the programme
author exactly as `usage:` was — not derived from a different question that
happens to correlate.
`usage_daylight` therefore stays default off and is **not** the shipping fix.
It is retained as the mechanism — the compact-side clip is the right shape for
the factor — pending a `daylight:` attribute to key it on.
## 39. Config audit: requirements that actively fight the engine (`homemaker-py-ju3`) — measured 2026-08-25
The corpus `patterns.config` targets and `costs.config` values were estimated
@ -5422,3 +5624,277 @@ missed, because consulting rooms and storage stood in for them. With that
substitution gone, `homemaker-py-2v1` (connectivity priced at ×0.5 against a ×6
circulation→habitable value gap) is the remaining half of the same problem —
and now measurable, because the fails it should be preventing actually fire.
### 39.8 `homemaker-py-2v1` connectivity weighting — MEASURED NULL, premise retracted
§38.2 concluded that the objective is net-positive on severing a level's
circulation: merging a corridor into a habitable sibling gains
`value_inside / value_circulation` = ×6, while `level N not connected` costs
only ×0.5, so break-even needs `0.5^w < 50/300`, i.e. w > 2.58 — "severing must
cost at least 3 fails and costs 1". **The arithmetic is right and the premise is
wrong.**
**What shipped anyway** (EXPERIMENTAL, default off, byte-identical):
`fitness.connectivity_weight_for(value_inside, value_circulation)` returns the
smallest weight making severing net-negative — 3.0 at the defaults, *derived*
from the rates rather than hard-coded, so it tracks them if either is retuned.
`conf["connectivity_weight"]` takes `1.0` (default, the flat rule), `"auto"`, or
an explicit number, and counts each connectivity failure as w failures in the
`0.5^n` penalty. `is_connectivity_fail` identifies the two strings.
**The measurement.** At `auto` (=3) the §38.2 deletion test does not move at
all: 5/25 deletions rewarded either way, median ×0.26 vs ×0.27. The reason is
immediate once looked for — **the connectivity fail count is unchanged in every
rewarded deletion**:
| seed | deleted | | score | fails | connectivity fails |
|---|---|---|---|---|---|
| 0 | `rlrrr` `O` | buried | ×238 | 115 → 107 | 5 → **5** |
| 0 | `rrrl` `O` | lit | ×346 | 115 → 106 | 5 → **5** |
| 1 | `lrlll` `cr1` | lit | ×257 | 107 → 99 | 3 → **3** |
| 2 | `rlrrl` `O` | buried | ×127 | 78 → 71 | 3 → **3** |
Weighting a failure that never fires changes nothing. And when the deletion
*does* break connectivity, the objective already punishes it — every such case
across harbor-house and maple-court, 4 seeds each:
| programme | deletions sampled | break connectivity | of those, rewarded |
|---|---|---|---|
| harbor-house | 32 | 2 | **0** (×0.00, ×0.01) |
| maple-court | 32 | 5 | **0** (×0.58 … ×0.07) |
So severing is already net-negative: it costs 12 connectivity failures *plus*
the cascade that follows them (inaccessible space, broken adjacency), and that
total already outweighs the ×6 value gain. The flat rule was never the problem.
**Where §38.2 went wrong.** The ×4.06 "well-daylit circulation leaf" that
motivated the whole bead was a deletion that did **not** change the
connectivity fail count. It was rewarded for removing the leaf's own quality
failures — §38.1's zero-value finding — and was misread as evidence for a
pricing mechanism. Two lessons, both cheap to state and expensive to learn: a
plausible closed-form arithmetic is not a measurement, and when a fix produces
*exactly* no effect, suspect the premise before the implementation.
**What is still true from §38.** §38.1 (buried leaves score a hard quality of
zero and contribute no value) and §38.3 (the frontage budget) are direct
measurements and stand. §39.7's finding — that the connectivity model was ~4×
too permissive — also stands and is the more useful lever: it made the fails
*fire*, where this bead would only have made them *cost more*.
**Verdict: NULL.** The flag stays default off with this write-up, per house
style for a measured-null lever. `homemaker-py-2v1` is closed. Why
`level 0/1 not connected` survive in the best-known layout is re-opened as a
question (`homemaker-py-yql`) — the evidence now says it is a reachability problem
(connected topologies are hard to construct and hold onto), not an incentive
one. It is newly measurable: §39.7 made the fails fire on constructed seeds
instead of being hidden by routes through store cupboards.
### 39.9 Why `level N not connected` persists: the resize destroys it (`homemaker-py-yql`)
§39.8 closed `2v1` NULL — severing circulation is already punished, so the fail
is not something the search is paid to create. That left the real question: is a
connected layout **rarely constructed**, or **constructed and then lost**?
**Answer: constructed, then lost — at construction time, in the resize.**
`level N not connected` fires from `graph.connected_circulation`, which keeps
only the generic `C`/`S` leaves and asks whether *they* form one component.
Measured over 20 constructed seeds per programme
(`experiments/diag_connectivity_yql.py`):
| programme | levels connected | seeds fully connected |
|---|---|---|
| harbor-house | 21/40 (52%) | **1/20** |
| health-centre | 1/20 (5%) | **1/20** |
| maple-court | 39/60 (65%) | **0/20** |
Then the decisive control — the same seeds with `proportion_aware=False`, i.e.
skipping `_size_divisions_from_targets`:
| programme | with resize | **without resize** |
|---|---|---|
| harbor-house | 52% | **100%** |
| health-centre | 5% | **100%** |
| maple-court | 65% | **100%** |
`_assign_adjacency_aware` picks circulation as a **connected** dominating set —
and it succeeds every time. The resize then moves every wall to hit the
programme's area targets, and the shared boundaries the dominating set relied on
shrink or vanish. On health-centre, **41 of 49 circulation-to-circulation edges
are destroyed by the resize**, and surviving shared walls are squeezed to
0.541.11 m against a 1.2 m `door_width`, so they stop counting as edges at all.
This is exactly the failure mode §37.7 recorded for CP-SAT room assignment —
"resizing can shrink a shared-wall segment below the door-width adjacency
threshold, silently invalidating an edge the exact solve relied on" — but nobody
had looked for it in **circulation connectivity**, where it costs 3595 points.
**§39.7 cost check: zero.** The same measurement under prefix-inferred vs
declared usages is identical (52/5/65% both ways). `has_circulation` never trims
`C``C` edges, so the usage change could not and did not make connectivity
harder to achieve.
#### The obvious repair is a net loss — measured
`operators.repair_circulation_settled` applies §37.7's own alternating-
minimisation fix: after the geometry settles, re-connect circulation by retyping
the cheapest bridging leaves to `C` (preferring generic outside, then
unassigned, crossing a required room last — `mutate_bridge_circulation`'s cost
model). It works, completely:
| programme | levels connected, repair OFF | repair ON |
|---|---|---|
| harbor-house | 52% (1/20 seeds full) | **100% (20/20)** |
| health-centre | 5% (1/20) | **100% (20/20)** |
| maple-court | 65% (0/20) | **100% (20/20)** |
And it is still the wrong trade. Mean fails per constructed seed, 12 seeds:
| programme | total | hard | connectivity | missing-room |
|---|---|---|---|---|
| harbor-house | 96.6 → **108.9** | 46.0 → 56.2 | 3.7 → 2.0 | 14.2 → **19.2** |
| health-centre | 62.8 → **84.2** | 24.2 → 46.8 | 2.9 → 2.2 | 2.0 → **10.5** |
| maple-court | 141.8 → **156.6** | 57.4 → 69.2 | 4.7 → 3.5 | 14.8 → **19.8** |
Connectivity failures fall by 0.81.7; missing-room failures rise by 5.08.5,
because every leaf retyped to `C` displaces a required room and each displacement
costs a 35 fail cascade (§38.5). **Robbing Peter to pay Paul.** Kept default
off with this write-up, per house style for a measured-null lever.
**The lever is upstream, not downstream.** The repair is treating a symptom: the
connection should never be destroyed in the first place. The named next move is
to *preserve* it during the resize — constrain `_size_divisions_from_targets` so
a shared boundary between two circulation leaves cannot fall below `door_width`
— rather than to rebuild it afterwards at the cost of the programme. That is a
constraint on an existing solve rather than a new repair pass. Filed as
`homemaker-py-3z0`. Worth noting while there: `solver.py` already carries
`min_width_generic` (default 1.2) to stop generic leaves collapsing to slivers
— the same idea applied to a leaf's WIDTH rather than to a shared BOUNDARY
between two specific leaves, so the new constraint may belong beside it.
### 39.10 Preserving constructed connectivity through the resize (`homemaker-py-3z0`) — NULL, and it reframes §39.9
§39.9 established that `_size_divisions_from_targets` destroys the connected
circulation the seeder builds, and named the upstream fix: keep the connection
*during* the resize rather than rebuilding it after. Built and measured. **It
does not help, and the reason matters more than the lever.**
**Both halves of the re-cut do damage, in different proportions per programme.**
The resize changes each node's ratio *and* re-picks its rotation. Freezing the
rotations and letting only the ratios move (12 seeds, % of levels connected):
| programme | no resize | ratio only | full resize |
|---|---|---|---|
| harbor-house | 100% | 71% | 50% |
| health-centre | 100% | **8%** | 8% |
| maple-court | 100% | 92% | 67% |
health-centre is destroyed entirely by the ratio; maple-court mostly by the
rotation. So any fix has to be able to give back either.
**`operators._size_divisions_preserving_circulation`** snapshots every cut,
resizes, then reverts the cuts on the tree path between each
circulation-to-circulation pair the resize broke. It keeps the programme
completely intact — no retyping, no displacement, only geometry given back —
and it works on connectivity:
| programme | connected, OFF | ON | fails OFF → ON | hard OFF → ON |
|---|---|---|---|---|
| harbor-house | 50% | **92%** | 96.6 → **141.5** | 46.0 → 65.1 |
| health-centre | 8% | 17% | 62.8 → **76.9** | 24.2 → 25.8 |
| maple-court | 67% | **97%** | 141.8 → **175.8** | 57.4 → 70.9 |
(A first attempt reverted greedily — whichever single cut most reduced the
component count — and barely moved: it stalls on the plateau where no *one*
revert helps though two would. Targeting the specific broken pairs is what
made connectivity work.)
Reverting a cut gives back that subtree's area accuracy, and size failures
roughly double on harbor-house (5.2 → 11.4). The obvious defence is that these
are raw constructed seeds and the inner loop has not run yet — the resize exists
to *warm-start* the ratio optimiser, so a worse warm start might cost nothing
once it converges. **Tested, and the defence fails.** Full search, harbor-house,
12 000 evals, seed 1, both arms:
| | fails | hard | soft | connectivity |
|---|---|---|---|---|
| `preserve_circulation` OFF | **43** | **9** | 34 | **3** |
| `preserve_circulation` ON | 65 | 26 | 39 | 4 |
Worse on every axis — including connectivity itself, the thing it was built to
fix.
**The reframing.** §39.9's finding stands as a fact (the resize really does
destroy 41 of 49 circulation edges) but is **not actionable, because
construction-time connectivity is not what determines final connectivity**. The
search reaches 43 fails with 3 connectivity fails starting from a 50%-connected
seed; forcing the seed to 92% connected yields 65 fails and 4 connectivity
fails. The seeder's circulation topology is not the bottleneck — the search
discards and rebuilds it either way, and constraining the seed only spends area
quality the search then cannot recover.
That also retires the framing this whole thread inherited from §38: connectivity
was never a construction problem *or* an incentive problem (§39.8). Both flags
(`repair_circulation`, `preserve_circulation`) stay default off with these
numbers recorded. **Do not revisit either without a new formulation** — the same
standing this document gives `bubble.py`.
### 39.11 The frontage bound, computed correctly (`homemaker-py-tdp`) — shipped as a pre-flight check
§38.3 derived a real constraint — every interior leaf needs
`L >= A/(1.6202·h)` of daylit wall — and then applied it to the wrong quantity.
It measured what a **fully built plot** would need. These programmes do not ask
for a fully built plot.
Recomputed against the area each programme actually demands:
| programme | demanded/storey | % of plot | frontage needed | supplied | gap | courtyard to close | spare plot | |
|---|---|---|---|---|---|---|---|---|
| harbor-house | 418 m² | 60% | 86 m | 53 m | +33 m | 49 m² | 277 m² | **OK** |
| maple-court | 338 m² | 44% | 70 m | 55 m | +15 m | 22 m² | 424 m² | **OK** |
| programme-house | 38 m² | 72% | 8 m | 22 m | 14 m | none | — | **OK** |
| health-centre | 240 m² | **131%** | 49 m | 41 m | +8 m | 12 m² | **57 m²** | **DOES NOT FIT** |
Plot area and frontage are measured through `geometry`, not from the raw
`init.dom` corners, so they carry the `wall_outer` inset and the plot rotation —
these are the metres and the square metres the leaves actually get. "Daylit"
means what `Fitness.area_outside` means by it: an external boundary counts
unless its perimeter type is `private` or `fortified`.
So harbor-house and maple-court are **not** frontage-infeasible; they need a
courtyard of 49 m² and 22 m² respectively, against 277 m² and 424 m² of spare
plot. §38.3's "2.7× short" overstated it by comparing against a building nobody
asked for.
**The one genuinely infeasible programme is health-centre, and not for daylight
reasons: it demands 240 m² of floor on a 183 m² plot.** That shows up
unmistakably in the geometry — every room comes out at **0.60×** its declared
target, 100% of them undersized, uniformly, no matter what the search does.
Contrast harbor-house and maple-court, where the seeder hits targets almost
exactly (median area / (target × share) = **1.01×**).
*(An intermediate measurement suggested rooms were systematically inflated to
1.261.65× target. That was an artefact of not dividing by a shared leaf's
multiplicity — a leaf covering k rooms is legitimately k× a single target.
Corrected above; the seeder's sizing is accurate where the plot allows.)*
**Shipped: `evolve._preflight`.** Both checks now run at startup and print a
warning before a multi-hour run bottoms out against something no amount of
searching can fix:
```
WARNING: programme demands 240 m2 per storey on a 183 m2 plot (131%). Every room
will be squeezed below its target however long the search runs.
WARNING: 418 m2 per storey needs ~86 m of daylit wall; the plot's non-private
perimeter gives 53 m. Roughly 49 m2 of courtyard closes the gap.
```
Advisory only — it never blocks a run, since an author may be deliberately
exploring an over-tight brief. Silent on programme-house. The same numbers are
available in full from `experiments/diag_exposure_frontage.py frontage`.
**What this means for §38.** The frontage bound survives as a *diagnostic* and
is now correctly calibrated, but it does **not** say the corpus is
unsatisfiable. Of the four programmes, three fit their plots and one does not —
and that one fails a much cruder test than daylight. §38.3's claim that the
plateau programmes are "frontage-infeasible as specified" is withdrawn.

View file

@ -0,0 +1,600 @@
node:
- - 0.0
- 0.0
- - 25.0
- 2.0
- - 23.0
- 31.0
- - 0.0
- 31.0
perimeter:
a: private
b: private
c: null
d: null
rotation: 1
division:
- 0.5453707551767608
- 0.5453707551767608
height: 3.0
elevation: 0.0
wall_inner: 0.08
wall_outer: 0.25
l:
rotation: 1
division:
- 0.5522887920141253
- 0.5522887920141253
l:
rotation: 3
division:
- 0.5422240475759911
- 0.5422240475759911
l:
rotation: 3
division:
- 0.5884031332206187
- 0.5884031332206187
l:
type: da1
rotation: 0
r:
rotation: 1
division:
- 0.4351188444584838
- 0.4351188444584838
l:
rotation: 3
division:
- 0.5
- 0.5
l:
type: C
rotation: 0
r:
type: st1
rotation: 3
r:
type: k1
rotation: 0
r:
rotation: 1
division:
- 0.5858843654327277
- 0.5858843654327277
l:
rotation: 1
division:
- 0.6692443578136734
- 0.6692443578136734
l:
rotation: 3
division:
- 0.7222802898935807
- 0.7222802898935807
l:
rotation: 0
division:
- 0.5791570312500001
- 0.5791570312500001
l:
rotation: 3
division:
- 0.5
- 0.5
l:
type: O
rotation: 0
r:
type: t
rotation: 0
r:
type: cr1
rotation: 0
r:
type: cr1
rotation: 0
r:
type: O
rotation: 1
r:
type: ws1
rotation: 0
r:
rotation: 1
division:
- 0.6234470574437853
- 0.6234470574437853
l:
rotation: 0
division:
- 0.5902842616284559
- 0.5902842616284559
l:
rotation: 1
division:
- 0.7385208215717542
- 0.7385208215717542
l:
type: n
rotation: 0
r:
type: O
rotation: 0
r:
type: n
rotation: 0
r:
rotation: 1
division:
- 0.7824086560875603
- 0.7824086560875603
l:
rotation: 0
division:
- 0.7215682159962162
- 0.7215682159962162
l:
type: r
rotation: 0
r:
rotation: 1
division:
- 0.5936359570312502
- 0.5936359570312502
l:
type: st1
rotation: 0
r:
type: cr1
rotation: 0
r:
type: C
rotation: 1
r:
rotation: 1
division:
- 0.6570350194962238
- 0.6570350194962238
l:
rotation: 0
division:
- 0.38409579126711796
- 0.38409579126711796
l:
rotation: 3
division:
- 0.7722513353285644
- 0.7722513353285644
l:
rotation: 0
division:
- 0.629026904149584
- 0.629026904149584
l:
type: m
rotation: 0
r:
type: C
rotation: 3
r:
type: ef1
rotation: 3
r:
rotation: 1
division:
- 0.5923752795527412
- 0.5923752795527412
l:
rotation: 3
division:
- 0.8429374722623478
- 0.8429374722623478
l:
type: n
rotation: 0
r:
type: la1
rotation: 0
r:
rotation: 1
division:
- 0.6090773265385678
- 0.6090773265385678
l:
rotation: 3
division:
- 0.9561064910184571
- 0.9561064910184571
l:
rotation: 3
division:
- 0.6385206269531251
- 0.6385206269531251
l:
type: st2
rotation: 0
r:
type: C
rotation: 3
r:
type: O
rotation: 0
r:
type: O
rotation: 1
r:
rotation: 1
division:
- 0.646127856061397
- 0.646127856061397
l:
rotation: 2
division:
- 0.23548715480909932
- 0.23548715480909932
l:
rotation: 3
division:
- 0.6084768559570314
- 0.6084768559570314
l:
type: m
rotation: 0
r:
rotation: 0
division:
- 0.9564169654832019
- 0.9564169654832019
l:
type: t
rotation: 0
r:
type: st2
rotation: 0
r:
type: O
rotation: 3
r:
rotation: 1
division:
- 0.8784173200136786
- 0.8784173200136786
l:
rotation: 0
division:
- 0.4161469597784059
- 0.4161469597784059
l:
type: m
rotation: 1
r:
type: C
rotation: 1
r:
type: O
rotation: 1
above:
rotation: 0
division:
- 0.5326764625682993
- 0.5326764625682993
height: 3.0
l:
rotation: 0
division:
- 0.5536579163160164
- 0.5536579163160164
l:
rotation: 0
division:
- 0.4853471066909839
- 0.4853471066909839
l:
rotation: 0
division:
- 0.7987297048921393
- 0.7987297048921393
l:
rotation: 0
division:
- 0.7297117619482362
- 0.7297117619482362
l:
rotation: 3
division:
- 0.7579431477526928
- 0.7579431477526928
l:
type: O
rotation: 0
r:
rotation: 3
division:
- 0.6236887773559573
- 0.6236887773559573
l:
type: t
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 1
division:
- 0.5650312500000001
- 0.5650312500000001
l:
type: C
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 0
division:
- 0.5
- 0.5
l:
rotation: 1
division:
- 0.5
- 0.5
l:
type: r
rotation: 0
r:
type: st1
rotation: 0
r:
rotation: 3
division:
- 0.551578125
- 0.551578125
l:
type: O
rotation: 0
r:
type: r
rotation: 0
r:
rotation: 0
division:
- 0.43356671101589384
- 0.43356671101589384
l:
rotation: 0
division:
- 0.13133577297899704
- 0.13133577297899704
l:
rotation: 1
division:
- 0.5
- 0.5
l:
rotation: 0
division:
- 0.5797285677939966
- 0.5797285677939966
l:
rotation: 0
division:
- 0.5
- 0.5
l:
type: O
rotation: 0
r:
type: t
rotation: 0
r:
type: C
rotation: 0
r:
type: C
rotation: 0
r:
type: O
rotation: 0
r:
rotation: 0
division:
- 0.551578125
- 0.551578125
l:
type: st1
rotation: 0
r:
rotation: 3
division:
- 0.670845733692627
- 0.670845733692627
l:
type: li1
rotation: 0
r:
type: st1
rotation: 0
r:
rotation: 0
division:
- 0.781888627381648
- 0.781888627381648
l:
rotation: 0
division:
- 0.38499157916891213
- 0.38499157916891213
l:
rotation: 0
division:
- 0.2012702951078606
- 0.2012702951078606
l:
type: n
rotation: 0
r:
type: O
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 0
division:
- 0.4444444444444444
- 0.4444444444444444
l:
rotation: 0
division:
- 0.9730225242044375
- 0.9730225242044375
l:
type: r
rotation: 2
r:
rotation: 1
division:
- 0.5004934210526315
- 0.5004934210526315
l:
type: st2
rotation: 0
r:
type: t
rotation: 0
r:
rotation: 0
division:
- 0.5945754854305073
- 0.5945754854305073
l:
type: r
rotation: 0
r:
type: r
rotation: 0
r:
rotation: 0
division:
- 0.4902664331159802
- 0.4902664331159802
l:
rotation: 0
division:
- 0.6499007975748095
- 0.6499007975748095
l:
rotation: 0
division:
- 0.5193481864706366
- 0.5193481864706366
l:
rotation: 0
division:
- 0.26078324488789595
- 0.26078324488789595
l:
rotation: 0
division:
- 0.5136069351999384
- 0.5136069351999384
l:
type: ut1
rotation: 3
r:
type: O
rotation: 0
r:
type: me1
rotation: 3
r:
rotation: 0
division:
- 0.5
- 0.5
l:
type: C
rotation: 0
r:
type: r
rotation: 0
r:
rotation: 0
division:
- 0.2873334582149999
- 0.2873334582149999
l:
rotation: 0
division:
- 0.375
- 0.375
l:
type: n
rotation: 0
r:
type: r
rotation: 0
r:
type: O
rotation: 0
r:
rotation: 0
division:
- 0.4595786624530838
- 0.4595786624530838
l:
rotation: 0
division:
- 0.9210526315789473
- 0.9210526315789473
l:
type: O
rotation: 0
r:
rotation: 3
division:
- 0.7294856269372924
- 0.7294856269372924
l:
type: st1
rotation: 0
r:
rotation: 3
division:
- 0.7039689912158207
- 0.7039689912158207
l:
type: r
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 0
division:
- 0.5559478988311726
- 0.5559478988311726
l:
rotation: 0
division:
- 0.2012702951078606
- 0.2012702951078606
l:
type: r
rotation: 0
r:
rotation: 0
division:
- 0.5936359570312502
- 0.5936359570312502
l:
type: C
rotation: 3
r:
type: t
rotation: 1
r:
type: O
rotation: 2

View file

@ -0,0 +1,336 @@
node:
- - 0.0
- 0.0
- - 25.0
- 2.0
- - 23.0
- 31.0
- - 0.0
- 31.0
perimeter:
a: private
b: private
c: null
d: null
rotation: 1
division:
- 0.4888900105484264
- 0.4888900105484264
height: 3.0
elevation: 0.0
wall_inner: 0.08
wall_outer: 0.25
l:
rotation: 1
division:
- 0.5938247521970288
- 0.5938247521970288
l:
rotation: 0
division:
- 0.5472887522186323
- 0.5472887522186323
l:
rotation: 1
division:
- 0.7482565199547276
- 0.7482565199547276
l:
rotation: 2
division:
- 0.7634740326506355
- 0.7634740326506355
l:
type: da1
rotation: 0
r:
rotation: 3
division:
- 0.5
- 0.5
l:
type: st1
rotation: 0
r:
type: O
rotation: 0
r:
type: k1
rotation: 0
r:
rotation: 1
division:
- 0.48227518337297415
- 0.48227518337297415
l:
type: ws1
rotation: 0
r:
type: C
rotation: 2
r:
rotation: 1
division:
- 0.2876274429075646
- 0.2876274429075646
l:
rotation: 1
division:
- 0.4977615341676925
- 0.4977615341676925
l:
type: C
rotation: 3
r:
rotation: 1
division:
- 0.5260108852851195
- 0.5260108852851195
l:
type: ef1
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 1
division:
- 0.6397932334327496
- 0.6397932334327496
l:
type: t
share: 3
rotation: 0
r:
rotation: 1
division:
- 0.554484404426573
- 0.554484404426573
l:
type: la1
rotation: 0
r:
type: st1
rotation: 0
r:
rotation: 1
division:
- 0.8502200490349778
- 0.8502200490349778
l:
rotation: 0
division:
- 0.8442580239222437
- 0.8442580239222437
l:
rotation: 1
division:
- 0.3758880797422988
- 0.3758880797422988
l:
type: n
share: 3
rotation: 0
r:
type: of
rotation: 0
r:
rotation: 3
division:
- 0.5154341184740678
- 0.5154341184740678
l:
type: m
share: 3
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 3
division:
- 0.2616194760651602
- 0.2616194760651602
l:
type: st1
rotation: 0
r:
rotation: 2
division:
- 0.5989008688013605
- 0.5989008688013605
l:
type: me1
rotation: 0
r:
type: O
rotation: 0
above:
rotation: 0
division:
- 0.5662620679201393
- 0.5662620679201393
height: 3.0
l:
rotation: 0
division:
- 0.5300569106355991
- 0.5300569106355991
l:
rotation: 0
division:
- 0.0863852684329759
- 0.0863852684329759
l:
type: t
share: 3
rotation: 0
r:
rotation: 0
division:
- 0.369644807975687
- 0.369644807975687
l:
rotation: 0
division:
- 0.5
- 0.5
l:
rotation: 3
division:
- 0.5125
- 0.5125
l:
type: C
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 1
division:
- 0.6910915096691898
- 0.6910915096691898
l:
type: O
rotation: 0
r:
type: C
rotation: 0
r:
type: n
share: 2
rotation: 0
r:
rotation: 0
division:
- 0.4566953630047795
- 0.4566953630047795
l:
rotation: 0
division:
- 0.16593799911395812
- 0.16593799911395812
l:
type: ut1
rotation: 0
r:
rotation: 3
division:
- 0.525234375
- 0.525234375
l:
type: C
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 0
division:
- 0.2988974834269737
- 0.2988974834269737
l:
type: r
share: 3
rotation: 0
r:
rotation: 0
division:
- 0.5
- 0.5
l:
type: O
rotation: 0
r:
type: O
rotation: 0
r:
rotation: 0
division:
- 0.5670341721910087
- 0.5670341721910087
l:
rotation: 0
division:
- 0.5878540477469637
- 0.5878540477469637
l:
rotation: 0
division:
- 0.2988974834269737
- 0.2988974834269737
l:
type: r
share: 3
rotation: 0
r:
type: st2
rotation: 0
r:
rotation: 3
division:
- 0.5
- 0.5
l:
rotation: 3
division:
- 0.6580826806640626
- 0.6580826806640626
l:
rotation: 0
division:
- 0.5
- 0.5
l:
type: C
rotation: 0
r:
type: C
rotation: 0
r:
type: C
rotation: 0
r:
type: C
rotation: 0
r:
rotation: 0
division:
- 0.6164728327412322
- 0.6164728327412322
l:
type: r
rotation: 0
r:
rotation: 0
division:
- 0.6
- 0.6
l:
type: r
share: 3
rotation: 0
r:
type: li1
rotation: 0

View file

@ -16,6 +16,17 @@ buried or lit is what separates the two mechanisms:
against a ×0.5 connectivity penalty (§38.2 refinement) which no
``crinkliness_mode`` can touch.
.. warning::
**The premise of this test is RETRACTED see §38.2 and §38.8.** The ×6-vs-
×0.5 arithmetic assumed severing the spine costs one connectivity fail;
measured, the connectivity count is unchanged in every rewarded deletion, so
this script is not measuring what its docstring says. It is kept because the
per-mode buried/lit split is still a useful description of what each mode
touches, but a mode does **not** pass or fail on these numbers. The A/B that
decides `ssz` is ``experiments/ab_ssz_search.py`` (fixed-budget search,
scored under the stock objective).
Usage::
python experiments/ab_crinkliness_mode_ssz.py
@ -34,7 +45,8 @@ from homemaker_layout import driver, fitness, geometry
from homemaker_layout import graph as graph_mod
from homemaker_layout import operators, programme
MODES = ("urb", "floor", "compact_ok", "exempt_circulation")
MODES = ("urb", "floor", "compact_ok", "exempt_circulation",
"usage_daylight")
def make_fitness(progdir: str, mode: str) -> fitness.Fitness:
@ -74,7 +86,12 @@ def unpinned_leaves(fit: fitness.Fitness, root: dom_mod.Node) -> list[tuple]:
for leaf in lvl.leaves():
if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf):
continue
if (leaf.type or "")[:1].upper() not in ("C", "O"):
# §39.4: generic structural types are EXACT `C`/`O`/`S`, never a
# first-character prefix -- `cr1` is a programme room. This script
# predates that rule and its original `type[:1].upper() in ("C","O")`
# test swept programme rooms into the "unpinned" set, which is one
# reason the §38.6 numbers do not reproduce.
if not dom_mod.is_generic(leaf.type):
continue
out.append((li, leaf.id, leaf.type,
fit.area_outside(leaf, graphs[li], {})))

View file

@ -0,0 +1,145 @@
"""Fixed-budget search A/B for the crinkliness modes (`homemaker-py-ssz`).
DESIGN.md §38.6 A/B'd the modes against the §38.2 *deletion test*, which has
since been retracted, and it used the pre-§39.4 `type[:1] in ("C","O")` prefix
rule that mislabels programme rooms as circulation. So the modes have never
been measured against what `ssz`'s acceptance criteria actually asks for: a
fixed-budget search, hard/soft fail split, on harbor-house and maple-court.
**The scoring discipline is the point of this script.** `compact_ok`,
`exempt_circulation` and `usage_daylight` all return 1.0 for leaves that stock
scores below FAIL_THRESHOLD, so scoring an arm under its own objective deletes
a fail category for free and every arm "wins". Two numbers are therefore
reported per arm:
urb the arm's final layout re-scored under the STOCK objective. This is
the comparable yardstick, and the one that answers "did optimising
under this variant steer the search to a better building?"
own the same layout under the arm's own objective. Lower than `urb` by
construction for the permissive modes; it is reported only so the
size of the definitional discount is visible, never as the result.
A mode passes on `urb`, not on `own`.
Usage::
python experiments/ab_ssz_search.py --budget 3000 --seeds 3
python experiments/ab_ssz_search.py --modes urb usage_daylight --seeds 2
"""
from __future__ import annotations
import argparse
import collections
import copy
import csv
import time
from pathlib import Path
from homemaker_layout import dom as dom_mod
from homemaker_layout import driver, fitness
CORPUS = ["examples/harbor-house", "examples/maple-court"]
MODES = ["urb", "floor", "compact_ok", "exempt_circulation", "usage_daylight"]
def _with_mode(mode: str):
"""Patch `fitness.load_config` so every evaluator built during the run --
the driver's, the inner loop's, the seeder's -- sees `crinkliness_mode`.
`driver.search` has no parameter for it, and `driver._fitness_for` is
lru_cached on its arguments, so the cache is cleared around the patch or a
later arm would silently reuse the previous arm's evaluator.
"""
orig = fitness.load_config
def patched(directory, overrides=None):
ov = dict(overrides or {})
ov["crinkliness_mode"] = mode
return orig(directory, overrides=ov)
return orig, patched
def tiers(fails) -> tuple[int, int]:
c = collections.Counter(fitness.classify_fail_tier(f) for f in fails)
return c["hard"], c["soft"]
def run_arm(progdir: str, seed: int, mode: str, budget: int,
child_budget: int) -> dict:
orig, patched = _with_mode(mode)
fitness.load_config = patched
driver._fitness_for.cache_clear()
t0 = time.perf_counter()
try:
res = driver.search(
dom_mod.load(f"{progdir}/init.dom"), progdir,
budget=budget, seed=seed, child_budget=child_budget, n_workers=1)
root = copy.deepcopy(res.best.root)
own_conf, own_cost = patched(progdir, overrides={"leaf_sharing": True,
"collapse_insearch": True})
_, own_fails = fitness.Fitness(own_conf, own_cost).score_with_fails(
copy.deepcopy(root))
finally:
fitness.load_config = orig
driver._fitness_for.cache_clear()
# the comparable yardstick: stock objective, same layout
conf, cost = orig(progdir, overrides={"leaf_sharing": True,
"collapse_insearch": True})
_, urb_fails = fitness.Fitness(conf, cost).score_with_fails(copy.deepcopy(root))
uh, us = tiers(urb_fails)
oh, os_ = tiers(own_fails)
return dict(programme=Path(progdir).name, seed=seed, mode=mode,
urb_hard=uh, urb_soft=us, urb_total=uh + us,
own_hard=oh, own_soft=os_, own_total=oh + os_,
elapsed_s=round(time.perf_counter() - t0, 1))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--budget", type=int, default=3000)
ap.add_argument("--child-budget", type=int, default=80)
ap.add_argument("--seeds", type=int, default=3)
ap.add_argument("--modes", nargs="+", default=MODES)
ap.add_argument("--corpus", nargs="+", default=CORPUS)
ap.add_argument("--out", default="experiments/results/ab_ssz_search.csv")
args = ap.parse_args()
rows = []
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
for progdir in args.corpus:
for mode in args.modes:
for seed in range(args.seeds):
r = run_arm(progdir, seed, mode, args.budget, args.child_budget)
rows.append(r)
print(f" {r['programme']:<14} {mode:<20} seed={seed} "
f"urb {r['urb_hard']}h/{r['urb_soft']}s "
f"(own {r['own_hard']}h/{r['own_soft']}s) "
f"{r['elapsed_s']}s", flush=True)
with out.open("w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
print(f"\n=== stock-objective (urb) fail counts, budget {args.budget} ===")
print(f" {'programme':<14}{'mode':<22}{'hard':<14}{'soft':<14}total")
print(" " + "-" * 70)
for progdir in args.corpus:
name = Path(progdir).name
for mode in args.modes:
sel = [r for r in rows if r["programme"] == name and r["mode"] == mode]
if not sel:
continue
h = sum(r["urb_hard"] for r in sel) / len(sel)
s = sum(r["urb_soft"] for r in sel) / len(sel)
print(f" {name:<14}{mode:<22}{h:<14.1f}{s:<14.1f}{h + s:.1f}")
print(f"\nwrote {out}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,132 @@
"""Are the crinkliness failures the objective emits real defects? (`homemaker-py-ssz`)
Not an A/B. This asks a correctness question the search cannot answer: of the
`crinkliness` failures the STOCK objective reports, how many are on a space
that architecturally wants daylight at all?
A `crinkliness` fail says "this leaf has too little exposed wall for its area".
For a bedroom or a living room that is a real defect. For a broom cupboard, a
WC, a plant room, an internal corridor or a covered courtyard it is not -- those
are ordinary buried architecture, and the fail is an artefact of applying one
daylight requirement to every space regardless of use (DESIGN.md §38.8).
Every fail is classified by the leaf's DECLARED `usage:` (§39.7), so nothing
here rests on how a code is spelled.
Usage::
python experiments/audit_crinkliness_truth.py
python experiments/audit_crinkliness_truth.py --seeds 5
python experiments/audit_crinkliness_truth.py --dom examples/harbor-house/generated.dom
"""
from __future__ import annotations
import argparse
import collections
import copy
from pathlib import Path
import numpy as np
from homemaker_layout import dom as dom_mod
from homemaker_layout import driver, fitness, geometry
from homemaker_layout import graph as graph_mod
from homemaker_layout import operators, programme
CORPUS = ["examples/harbor-house", "examples/maple-court", "examples/health-centre"]
def stock_fitness(progdir: str) -> fitness.Fitness:
"""Stock objective -- `crinkliness_mode` left at its "urb" default."""
ov = dict(driver._overrides_for(
leaf_sharing=True, superpose=False, max_share=None, conn_grade=False,
collapse_insearch=True, multi_use=False) or {})
conf, cost = fitness.load_config(progdir, overrides=ov)
return fitness.Fitness(conf, cost)
def constructed(progdir: str, s: int) -> dom_mod.Node:
reqs = programme.load_programme_dir(progdir)
return operators.constructive_topology(
dom_mod.load(f"{progdir}/init.dom"), reqs, np.random.default_rng(s),
sorted(reqs) + ["C", "O"], min_storeys=programme.storey_minimum(progdir),
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
leaf_sharing=True, leaf_share_factor=3, depth_balanced=True,
interior_outside=True, outside_divisor=3)
def audit(fit: fitness.Fitness, root: dom_mod.Node) -> collections.Counter:
"""usage -> count, over the leaves that emit a stock `crinkliness` fail."""
tree = copy.deepcopy(root)
geometry.clear_cache()
dom_mod.canonicalize_shares(tree)
fit.preprocess_building(tree)
dom_mod.merge_divided(tree)
geometry.clear_cache()
graphs = graph_mod.build_graphs(tree, fit.conf("door_width") or 1.2)
out: collections.Counter = collections.Counter()
for li, lvl in enumerate(dom_mod.levels(tree)):
groups = geometry.boundary_groups(lvl)
for leaf in lvl.leaves():
if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf):
continue
if fit.quality_uncrinkliness(leaf, graphs[li], groups) >= fitness.FAIL_THRESHOLD:
continue # not a failure
out[fit.usage_of(leaf) or f"<generic {leaf.type}>"] += 1
return out
def report(label: str, tally: collections.Counter) -> tuple[int, int]:
total = sum(tally.values())
real = sum(n for u, n in tally.items() if u in programme.DAYLIGHT_USAGES)
print(f"=== {label}: {total} crinkliness fails")
if not total:
print(" none\n")
return 0, 0
for usage, n in tally.most_common():
verdict = ("REAL DEFECT" if usage in programme.DAYLIGHT_USAGES
else "not a defect -- no daylight wanted")
print(f" {usage:<18}{n:>4} {verdict}")
print(f" -> {total - real}/{total} ({100 * (total - real) / total:.0f}%) "
f"are reported against spaces that do not want daylight\n")
return real, total
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--seeds", type=int, default=3)
ap.add_argument("--corpus", nargs="+", default=CORPUS)
ap.add_argument("--dom", nargs="*", default=[],
help="also audit these evolved .dom files (programme dir inferred)")
args = ap.parse_args()
print("Stock objective. Every fail classified by the leaf's declared usage.\n")
grand_real = grand_total = 0
for progdir in args.corpus:
fit = stock_fitness(progdir)
tally: collections.Counter = collections.Counter()
for s in range(args.seeds):
tally += audit(fit, constructed(progdir, s))
r, t = report(f"{Path(progdir).name} ({args.seeds} constructed seeds)", tally)
grand_real += r
grand_total += t
for dom_path in args.dom:
progdir = str(Path(dom_path).parent)
fit = stock_fitness(progdir)
r, t = report(f"{dom_path} (evolved)", audit(fit, dom_mod.load(dom_path)))
grand_real += r
grand_total += t
if grand_total:
print(f"OVERALL: {grand_total - grand_real}/{grand_total} "
f"({100 * (grand_total - grand_real) / grand_total:.0f}%) of the "
f"crinkliness failures the objective reports are not defects.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,167 @@
"""Why does `level N not connected` persist? (`homemaker-py-yql`, DESIGN.md §39.9)
`homemaker-py-2v1` closed NULL: severing a level's circulation is already
punished, so the fail is not something the search is paid to create. This asks
the follow-on question is a connected layout **rarely constructed**, or
**constructed and then lost**?
`level N not connected` fires from `graph.connected_circulation`, which keeps
only the generic circulation leaves (`C`/`S`) and asks whether *they* form one
connected component. It runs on `graph_circ`, i.e. AFTER
`graph.has_circulation` has trimmed edges, so §39.7's usage change can in
principle reach it report (b) measures whether it did.
Three reports:
construct what fraction of constructed seeds start connected, per level
cost the same, prefix-inferred usages vs declared (the §39.7 cost)
survive from a CONNECTED layout, how often does one mutation break
connectivity, and would the outer comparator keep the mutant
Usage::
python experiments/diag_connectivity_yql.py construct
python experiments/diag_connectivity_yql.py cost
python experiments/diag_connectivity_yql.py survive --seeds 40
"""
from __future__ import annotations
import argparse
import copy
from pathlib import Path
import numpy as np
from homemaker_layout import dom as dom_mod
from homemaker_layout import driver, fitness, geometry
from homemaker_layout import graph as graph_mod
from homemaker_layout import operators, programme
CORPUS = ["examples/harbor-house", "examples/health-centre", "examples/maple-court"]
LEGACY_PREFIX = {"b": "bedroom", "t": "toilet", "l": "living", "k": "kitchen"}
def make_fitness(progdir: str) -> fitness.Fitness:
overrides = driver._overrides_for(
leaf_sharing=True, superpose=False, max_share=None, conn_grade=False,
collapse_insearch=True, multi_use=False)
conf, cost = fitness.load_config(progdir, overrides=dict(overrides or {}))
return fitness.Fitness(conf, cost)
def constructed_seed(progdir: str, seed: int) -> dom_mod.Node:
reqs = programme.load_programme_dir(progdir)
return operators.constructive_topology(
dom_mod.load(f"{progdir}/init.dom"), reqs, np.random.default_rng(seed),
sorted(reqs) + ["C", "O"],
min_storeys=programme.storey_minimum(progdir),
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
leaf_sharing=True, leaf_share_factor=3, depth_balanced=True,
interior_outside=True, outside_divisor=3)
def connectivity(root: dom_mod.Node, usages: dict[str, str]) -> tuple[int, int]:
"""``(levels_connected, levels_total)`` for one tree.
Mirrors the scorer: build the circ graphs, then ask
``connected_circulation`` per level on a copy, exactly as
``process_storey`` does.
"""
tree = copy.deepcopy(root)
geometry.clear_cache()
dom_mod.canonicalize_shares(tree)
_, circ = graph_mod.build_graphs_with_circ(tree, 1.2, lambda _f: None, usages)
connected = sum(1 for gc in circ
if graph_mod.connected_circulation(gc.copy()))
return connected, len(circ)
def report_construct(seeds: int) -> None:
print(f"how often does a CONSTRUCTED seed start connected? ({seeds} seeds)\n")
print(f" {'programme':<18}{'levels connected':<20}{'seeds fully connected'}")
print(" " + "-" * 62)
for progdir in CORPUS:
fit = make_fitness(progdir)
usages = fit.usages()
ok = tot = full = 0
for s in range(seeds):
c, n = connectivity(constructed_seed(progdir, s), usages)
ok += c
tot += n
full += (c == n)
print(f" {Path(progdir).name:<18}{f'{ok}/{tot} ({100*ok/max(tot,1):.0f}%)':<20}"
f"{full}/{seeds}")
def report_cost(seeds: int) -> None:
"""Did §39.7's usage change make level connectivity harder to achieve?"""
print("§39.7 cost check — prefix-inferred usages vs declared "
f"({seeds} seeds)\n")
print(f" {'programme':<18}{'prefix-inferred':<20}{'declared':<20}delta")
print(" " + "-" * 68)
for progdir in CORPUS:
reqs = programme.load_programme_dir(progdir)
declared = {c: r.usage for c, r in reqs.items()}
legacy = {c: LEGACY_PREFIX.get(c[:1].lower(), "none") for c in reqs}
res = {}
for label, usages in (("legacy", legacy), ("declared", declared)):
ok = tot = 0
for s in range(seeds):
c, n = connectivity(constructed_seed(progdir, s), usages)
ok += c
tot += n
res[label] = (ok, tot)
(a, ta), (b, tb) = res["legacy"], res["declared"]
delta = 100 * b / max(tb, 1) - 100 * a / max(ta, 1)
print(f" {Path(progdir).name:<18}"
f"{f'{a}/{ta} ({100*a/max(ta,1):.0f}%)':<20}"
f"{f'{b}/{tb} ({100*b/max(tb,1):.0f}%)':<20}{delta:+.0f} pts")
def report_survive(seeds: int) -> None:
"""From a CONNECTED level, how fragile is that connectivity under one
mutation and would the comparator keep the mutant anyway?"""
print(f"survival of connectivity under one mutation ({seeds} trials)\n")
print(f" {'programme':<18}{'started connected':<20}{'broken by mutation':<22}"
f"{'…and kept by comparator'}")
print(" " + "-" * 82)
for progdir in CORPUS:
fit = make_fitness(progdir)
usages = fit.usages()
reqs = programme.load_programme_dir(progdir)
types = sorted(reqs) + ["C", "O"]
started = broken = kept = 0
rng = np.random.default_rng(0)
for s in range(seeds):
root = constructed_seed(progdir, s)
c, n = connectivity(root, usages)
if c != n:
continue # only study layouts that ARE connected
started += 1
base_score, base_fails = fit.score_with_fails(copy.deepcopy(root))
child, _desc = operators.mutate(root, rng, types, reqs=reqs)
c2, n2 = connectivity(child, usages)
if c2 == n2:
continue
broken += 1
# would the outer loop admit it? lexicographic (-n_fails, fitness)
score, fails = fit.score_with_fails(copy.deepcopy(child))
if (-len(fails), score) > (-len(base_fails), base_score):
kept += 1
print(f" {Path(progdir).name:<18}{f'{started}/{seeds}':<20}"
f"{f'{broken}/{max(started,1)}':<22}{kept}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("report", choices=("construct", "cost", "survive"))
ap.add_argument("--seeds", type=int, default=20)
args = ap.parse_args()
{"construct": report_construct, "cost": report_cost,
"survive": report_survive}[args.report](args.seeds)
if __name__ == "__main__":
main()

View file

@ -35,7 +35,6 @@ import math
from pathlib import Path
import numpy as np
import yaml
from homemaker_layout import dom as dom_mod
from homemaker_layout import driver, fitness, geometry
@ -176,6 +175,53 @@ def report_value(progdir: str, seed: int, limit: int) -> None:
f"({verdict}), fails {len(base_fails)} -> {len(fails)}")
def frontage_budget(progdir: str) -> dict:
"""Feasibility of a programme on its plot, before any search runs.
Two independent checks, in the order they bite:
1. **Does the programme fit the plot at all?** ``demand / storeys`` against
the plot area. health-centre asks for 240 on a 183 plot 131%
and every room comes out at 0.60x its target no matter what the search
does.
2. **Is there enough daylit wall for the area it does demand?** Every
interior leaf needs ``L >= A/(X*h)`` (§38.3), so a storey building
``A_built`` needs ``A_built/(X*h)`` metres. The plot's non-``private``
perimeter supplies some; interior courtyard supplies the rest, at roughly
``2 * area / width`` metres per courtyard slot.
NB this must be computed against the area the programme actually DEMANDS,
not a fully built plot see §39.11 for the correction.
"""
root = dom_mod.load(f"{progdir}/init.dom")
per = root.perimeter or {}
height = root.height or 3.0
# measured exactly as `Fitness.area_outside` does: an external boundary is
# daylit unless its perimeter type is `private` or `fortified`. Going
# through `geometry` rather than the raw YAML corners also picks up the
# `wall_outer` inset and the plot rotation, so these are the metres and the
# square metres the leaves actually get.
daylit = sum(geometry.edge_length(root, e) for e in range(4)
if (per.get(geometry.boundary_id(root, e)) or "").lower()
not in ("private", "fortified"))
plot = geometry.area(root)
reqs = programme.load_programme_dir(progdir)
storeys = max(programme.n_storeys_required(reqs),
programme.storey_minimum(progdir))
demand = sum(r.size * r.count for r in reqs.values())
built = demand / storeys
x_buried, _ = fail_bounds()
needed = built / (x_buried * height)
gap = needed - daylit
court = max(0.0, gap) * 3.0 / 2.0 # 3 m courtyard slots
spare = plot - built
return dict(plot=plot, daylit=daylit, height=height, storeys=storeys,
demand=demand, built=built, needed=needed, gap=gap,
court=court, spare=spare,
fits_plot=built <= plot,
frontage_ok=court <= spare)
def report_frontage(progdirs: list[str]) -> None:
x_buried, x_exposed = fail_bounds()
print(f"crinkliness fails when 1/crink > {x_buried:.4f} (buried) "
@ -183,35 +229,23 @@ def report_frontage(progdirs: list[str]) -> None:
print(f"=> every interior leaf needs exposed wall L >= A / ({x_buried:.4f} * h)\n")
for progdir in progdirs:
seed = yaml.safe_load(open(f"{progdir}/init.dom"))
corners, per = seed["node"], (seed.get("perimeter") or {})
height = seed.get("height") or 3.0
n = len(corners)
edges = [math.hypot(corners[(i + 1) % n][0] - corners[i][0],
corners[(i + 1) % n][1] - corners[i][1]) for i in range(n)]
daylit = sum(e for k, e in zip("abcd", edges)
if (per.get(k) or "").lower() not in ("private", "fortified"))
area = abs(sum(corners[i][0] * corners[(i + 1) % n][1]
- corners[(i + 1) % n][0] * corners[i][1]
for i in range(n))) / 2
reqs = programme.load_programme_dir(progdir)
n_storeys = max(programme.n_storeys_required(reqs),
programme.storey_minimum(progdir))
demand = sum(r.size * r.count for r in reqs.values())
needed = area / (x_buried * height)
b = frontage_budget(progdir)
print(f"=== {Path(progdir).name}")
print(f" plot {area:.0f} m2, perimeter {sum(edges):.0f} m, "
f"{n_storeys} storeys, h={height}")
print(f" perimeter {per} -> daylit frontage {daylit:.0f} m")
print(f" a fully built storey needs {needed:.0f} m exposed wall; "
f"plot supplies {daylit:.0f} m "
f"-> {needed / max(daylit, 1e-9):.1f}x short"
if needed > daylit else
f" a fully built storey needs {needed:.0f} m exposed wall; "
f"plot supplies {daylit:.0f} m -> FEASIBLE")
print(f" programme demands {demand:.0f} m2 over {n_storeys} storeys "
f"({demand / n_storeys:.0f} m2/storey of {area:.0f} m2 plot)\n")
print(f" plot {b['plot']:.0f} m2, daylit perimeter {b['daylit']:.0f} m, "
f"{b['storeys']} storeys, h={b['height']:g}")
pct = 100 * b["built"] / b["plot"]
verdict = "OK" if b["fits_plot"] else "DOES NOT FIT THE PLOT"
print(f" 1. programme demands {b['demand']:.0f} m2 -> {b['built']:.0f} m2 "
f"per storey = {pct:.0f}% of the plot [{verdict}]")
print(f" 2. that needs {b['needed']:.0f} m of daylit wall; perimeter gives "
f"{b['daylit']:.0f} m -> gap {b['gap']:+.0f} m")
if b["gap"] > 0:
print(f" closing it takes ~{b['court']:.0f} m2 of 3 m courtyard; "
f"spare plot {b['spare']:.0f} m2 "
f"[{'OK' if b['frontage_ok'] else 'NOT ENOUGH ROOM'}]")
else:
print(" perimeter alone is sufficient")
print()
def main() -> None:

View file

@ -0,0 +1,7 @@
programme,seed,mode,urb_hard,urb_soft,urb_total,own_hard,own_soft,own_total,elapsed_s
harbor-house,0,urb,16,39,55,16,39,55,76.4
harbor-house,1,urb,22,45,67,22,45,67,80.8
harbor-house,2,urb,22,40,62,22,40,62,76.6
harbor-house,3,urb,15,48,63,15,48,63,75.2
harbor-house,4,urb,17,42,59,17,42,59,83.9
harbor-house,5,urb,29,42,71,29,42,71,81.7
1 programme seed mode urb_hard urb_soft urb_total own_hard own_soft own_total elapsed_s
2 harbor-house 0 urb 16 39 55 16 39 55 76.4
3 harbor-house 1 urb 22 45 67 22 45 67 80.8
4 harbor-house 2 urb 22 40 62 22 40 62 76.6
5 harbor-house 3 urb 15 48 63 15 48 63 75.2
6 harbor-house 4 urb 17 42 59 17 42 59 83.9
7 harbor-house 5 urb 29 42 71 29 42 71 81.7

View file

@ -0,0 +1,31 @@
programme,seed,mode,urb_hard,urb_soft,urb_total,own_hard,own_soft,own_total,elapsed_s
harbor-house,0,urb,16,39,55,16,39,55,80.2
harbor-house,1,urb,22,45,67,22,45,67,88.7
harbor-house,2,urb,22,40,62,22,40,62,79.8
harbor-house,0,floor,16,38,54,16,38,54,78.9
harbor-house,1,floor,21,46,67,21,46,67,82.9
harbor-house,2,floor,20,41,61,20,41,61,79.0
harbor-house,0,compact_ok,16,39,55,16,22,38,78.7
harbor-house,1,compact_ok,20,44,64,20,20,40,82.6
harbor-house,2,compact_ok,10,55,65,10,34,44,81.4
harbor-house,0,exempt_circulation,18,37,55,18,34,52,77.2
harbor-house,1,exempt_circulation,19,49,68,19,42,61,81.0
harbor-house,2,exempt_circulation,21,42,63,21,37,58,79.0
harbor-house,0,usage_daylight,16,39,55,16,30,46,79.9
harbor-house,1,usage_daylight,21,51,72,21,30,51,82.3
harbor-house,2,usage_daylight,12,49,61,12,35,47,81.8
maple-court,0,urb,30,77,107,30,77,107,108.5
maple-court,1,urb,23,83,106,23,83,106,109.0
maple-court,2,urb,51,69,120,51,69,120,110.1
maple-court,0,floor,35,74,109,35,74,109,109.1
maple-court,1,floor,21,81,102,21,81,102,107.9
maple-court,2,floor,50,69,119,50,69,119,110.3
maple-court,0,compact_ok,30,78,108,30,47,77,109.8
maple-court,1,compact_ok,25,82,107,25,43,68,108.6
maple-court,2,compact_ok,52,70,122,52,33,85,105.9
maple-court,0,exempt_circulation,30,72,102,30,66,96,105.1
maple-court,1,exempt_circulation,25,81,106,25,70,95,106.6
maple-court,2,exempt_circulation,52,70,122,52,61,113,112.6
maple-court,0,usage_daylight,30,69,99,30,52,82,110.3
maple-court,1,usage_daylight,25,73,98,25,56,81,107.9
maple-court,2,usage_daylight,41,84,125,41,59,100,113.2
1 programme seed mode urb_hard urb_soft urb_total own_hard own_soft own_total elapsed_s
2 harbor-house 0 urb 16 39 55 16 39 55 80.2
3 harbor-house 1 urb 22 45 67 22 45 67 88.7
4 harbor-house 2 urb 22 40 62 22 40 62 79.8
5 harbor-house 0 floor 16 38 54 16 38 54 78.9
6 harbor-house 1 floor 21 46 67 21 46 67 82.9
7 harbor-house 2 floor 20 41 61 20 41 61 79.0
8 harbor-house 0 compact_ok 16 39 55 16 22 38 78.7
9 harbor-house 1 compact_ok 20 44 64 20 20 40 82.6
10 harbor-house 2 compact_ok 10 55 65 10 34 44 81.4
11 harbor-house 0 exempt_circulation 18 37 55 18 34 52 77.2
12 harbor-house 1 exempt_circulation 19 49 68 19 42 61 81.0
13 harbor-house 2 exempt_circulation 21 42 63 21 37 58 79.0
14 harbor-house 0 usage_daylight 16 39 55 16 30 46 79.9
15 harbor-house 1 usage_daylight 21 51 72 21 30 51 82.3
16 harbor-house 2 usage_daylight 12 49 61 12 35 47 81.8
17 maple-court 0 urb 30 77 107 30 77 107 108.5
18 maple-court 1 urb 23 83 106 23 83 106 109.0
19 maple-court 2 urb 51 69 120 51 69 120 110.1
20 maple-court 0 floor 35 74 109 35 74 109 109.1
21 maple-court 1 floor 21 81 102 21 81 102 107.9
22 maple-court 2 floor 50 69 119 50 69 119 110.3
23 maple-court 0 compact_ok 30 78 108 30 47 77 109.8
24 maple-court 1 compact_ok 25 82 107 25 43 68 108.6
25 maple-court 2 compact_ok 52 70 122 52 33 85 105.9
26 maple-court 0 exempt_circulation 30 72 102 30 66 96 105.1
27 maple-court 1 exempt_circulation 25 81 106 25 70 95 106.6
28 maple-court 2 exempt_circulation 52 70 122 52 61 113 112.6
29 maple-court 0 usage_daylight 30 69 99 30 52 82 110.3
30 maple-court 1 usage_daylight 25 73 98 25 56 81 107.9
31 maple-court 2 usage_daylight 41 84 125 41 59 100 113.2

View file

@ -316,6 +316,7 @@ def search(
shapecurve_prune: bool = False,
assign_solver: str = "greedy",
enable_reassign: bool = False,
preserve_circulation: bool = False,
) -> SearchResult:
"""Run the memetic loop from ``seed_root`` until ``budget`` oracle
evaluations are consumed. Returns the best individual found; its ``root``
@ -630,7 +631,8 @@ def search(
depth_balanced=depth_balanced,
interior_outside=interior_outside, outside_divisor=outside_divisor,
construction_beam_width=construction_beam_width,
multi_use=multi_use, assign_solver=assign_solver)
multi_use=multi_use, assign_solver=assign_solver,
preserve_circulation=preserve_circulation)
return (topo, None, child_budget, {}, f"construct/{tag}")
n = int(rng.integers(max(1, n_target - 1), n_target + 2))
return (random_topology(seed_root, n, rng, types), None, child_budget,

View file

@ -223,6 +223,60 @@ def _parse_args(argv=None) -> argparse.Namespace:
return p.parse_args(argv)
def _preflight(programme_dir) -> None:
"""Warn before the run if the programme cannot fit its plot (homemaker-py-tdp).
Two checks, cheap and closed-form (DESIGN.md §38.3/§39.11). Neither can be
fixed by searching harder, so it is worth saying so up front rather than
letting a multi-hour run bottom out against it:
1. does the demanded floor area fit the plot at all;
2. is there enough daylit wall for that area, given every interior leaf
needs ``L >= A/(1.6202*h)`` before it fails crinkliness.
"Daylit" is measured exactly as ``Fitness.area_outside`` does: an external
boundary counts unless its perimeter type is ``private`` or ``fortified``.
Advisory only it never blocks a run, since an author may deliberately be
exploring an over-tight brief.
"""
from . import geometry
from . import programme as _prog
try:
root = dom.load(f"{programme_dir}/init.dom")
per = root.perimeter or {}
daylit = sum(geometry.edge_length(root, e) for e in range(4)
if (per.get(geometry.boundary_id(root, e)) or "").lower()
not in ("private", "fortified"))
plot = geometry.area(root)
height = root.height or 3.0
reqs = _prog.load_programme_dir(str(programme_dir))
storeys = max(_prog.n_storeys_required(reqs),
_prog.storey_minimum(str(programme_dir)))
built = sum(r.size * r.count for r in reqs.values()) / max(storeys, 1)
except Exception:
return # advisory only; never block a run
if not plot or not daylit:
return
if built > plot:
print(f"WARNING: programme demands {built:.0f} m2 per storey on a "
f"{plot:.0f} m2 plot ({100 * built / plot:.0f}%). Every room will "
f"be squeezed below its target however long the search runs. "
f"(DESIGN.md §39.11)", file=sys.stderr)
needed = built / (1.6202 * height)
if needed > daylit:
court = (needed - daylit) * 1.5
note = (f", but only {plot - built:.0f} m2 of plot is spare"
if court > plot - built else "")
print(f"WARNING: {built:.0f} m2 per storey needs ~{needed:.0f} m of daylit "
f"wall; the plot's non-private perimeter gives {daylit:.0f} m. "
f"Roughly {court:.0f} m2 of courtyard closes the gap{note}. "
f"(DESIGN.md §38.3)", file=sys.stderr)
def main(argv=None) -> int:
args = _parse_args(argv)
@ -246,6 +300,8 @@ def main(argv=None) -> int:
else:
out = args.output.resolve()
_preflight(programme_dir)
print(f"seed : {seed_file}", file=sys.stderr)
print(f"programme : {programme_dir.name}", file=sys.stderr)
print(f"budget : {args.budget}", file=sys.stderr)

View file

@ -118,6 +118,40 @@ _SOFT_FAIL_MARKERS = (
)
# homemaker-py-2v1 (DESIGN.md §39.8) — the fails that punish severing a level's
# circulation. These are the ONLY counter-pressure against a structural x6 gain:
# deleting a circulation leaf merges it into its sibling, converting corridor
# into habitable area, and value_inside/value_circulation is 300/50.
_CONNECTIVITY_FAIL_MARKERS = ("not connected", "inaccessible usable space")
def is_connectivity_fail(fail: str) -> bool:
"""True for a level-connectivity failure (``level N not connected`` /
``N inaccessible usable space``)."""
return any(m in fail for m in _CONNECTIVITY_FAIL_MARKERS)
def connectivity_weight_for(value_inside: float, value_circulation: float) -> float:
"""Smallest integer weight at which severing circulation is net-NEGATIVE.
Merging a circulation leaf into a habitable sibling multiplies value by
``value_inside / value_circulation`` (x6 at the defaults). One failure costs
x0.5. So the penalty only outweighs the gain once
``0.5**w < value_circulation / value_inside``, i.e.
``w > log(vc/vi) / log(0.5)`` 2.58 at the defaults, hence 3.
Derived from the value rates rather than hard-coded, so the two stay in step
if either rate is ever retuned.
"""
import math
if value_inside <= 0 or value_circulation <= 0:
return 1.0
ratio = value_circulation / value_inside
if ratio >= 1.0: # circulation already worth as much
return 1.0
return float(math.ceil(math.log(ratio) / math.log(0.5)))
def classify_fail_tier(fail: str) -> str:
"""Return ``"hard"`` or ``"soft"`` for one failure string.
@ -390,9 +424,22 @@ class Fitness:
# leaf with no daylit wall. "urb" (default) = stock hard 0.0, byte-
# identical to every prior run. "floor"/"compact_ok"/"exempt_circulation"
# are the three candidate repairs — see quality_uncrinkliness.
# homemaker-py-2v1 (§39.8), EXPERIMENTAL: 1.0 (default) is the flat rule,
# byte-identical to every prior run. "auto" derives the smallest weight
# that makes severing circulation net-negative; a number sets it explicitly.
cw = self.conf("connectivity_weight")
if cw is None:
self._connectivity_weight = 1.0
elif isinstance(cw, str) and cw.lower() == "auto":
self._connectivity_weight = connectivity_weight_for(
float(self.conf("value_inside")),
float(self.conf("value_circulation")))
else:
self._connectivity_weight = float(cw)
self._crinkliness_mode = str(self.conf("crinkliness_mode") or "urb")
if self._crinkliness_mode not in (
"urb", "floor", "compact_ok", "exempt_circulation"):
"urb", "floor", "compact_ok", "exempt_circulation",
"usage_daylight"):
raise ValueError(
f"unknown crinkliness_mode: {self._crinkliness_mode!r}")
# The floored value stays BELOW FAIL_THRESHOLD, so a buried leaf still
@ -417,6 +464,16 @@ class Fitness:
req = (self._programme or {}).get(leaf.type)
return req.usage if req else ""
def needs_daylight(self, leaf: Node) -> bool:
"""Does this leaf's declared usage want a window? (homemaker-py-ssz)
True only for uses a person occupies (``programme.DAYLIGHT_USAGES``).
A generic ``C``/``O``/``S`` leaf has no programme entry and so is False,
which is the intended reading: a corridor or a covered courtyard is not
failing when it has no daylit wall.
"""
return self.usage_of(leaf) in _programme.DAYLIGHT_USAGES
# ------------------------------------------------------------------ #
# Type superposition + collapse (homemaker-py-9o5)
# ------------------------------------------------------------------ #
@ -1161,15 +1218,33 @@ class Fitness:
# (c) internal corridors are ordinary architecture; stop requiring
# every circulation leaf to reach daylight.
return 1.0
# (b)/(d) one-sided: being MORE compact than target is not a defect the
# way over-exposure is. Over-exposure still is one -- a crinkly leaf
# costs envelope whatever it is used for -- so this clips the compact
# side only, it does not switch the factor off.
#
# `compact_ok` applies that to every leaf; `usage_daylight` applies it
# only where nobody is sitting -- a store, a toilet, plant, a corridor,
# a covered courtyard -- and leaves habitable rooms on stock behaviour,
# so a windowless bedroom is still the hard failure it should be.
one_sided = mode == "compact_ok" or (
mode == "usage_daylight" and not self.needs_daylight(leaf))
if not crink:
# Zero exposure IS the compact limit (1/crink -> inf), so a
# one-sided factor has to score it 1.0. Reaching here and returning
# the floor instead was the flaw in the first `compact_ok`: it
# announced that compact is not a defect and then punished the most
# compact case of all hardest (§38.8).
if one_sided:
return 1.0
# (a) floor: keep buried leaves rankable by their other factors
# instead of collapsing the whole quality product to zero.
return self._crinkliness_floor if mode in ("floor", "compact_ok") else 0.0
return self._crinkliness_floor if mode == "floor" else 0.0
q = gaussian(1 / crink, 1.0, distance, sigma)
if mode == "compact_ok" and 1 / crink > distance:
# (b) one-sided: being MORE compact than target is not a defect the
# way over-exposure is, so clip to 1.0 on the compact side rather
# than decaying symmetrically into a fail.
if one_sided and 1 / crink > distance:
return 1.0
return max(q, self._crinkliness_floor) if mode in ("floor", "compact_ok") else q
@ -1925,7 +2000,22 @@ class Fitness:
building_factor = self.evaluate_building(root, tracking)
value *= building_factor
# 0.5^n failure penalty (programme-driven mode, not 0.1^n)
# 0.5^n failure penalty (programme-driven mode, not 0.1^n).
#
# homemaker-py-2v1: connectivity failures may carry EXTRA weight. Under
# the flat rule every failure costs x0.5, but severing a level's
# circulation *gains* value_inside/value_circulation = x6 (the corridor
# becomes habitable area when it merges into its sibling), so the
# objective was net-positive on destroying the spine — measured x4.06 on
# a well-daylit circulation leaf. ``connectivity_weight`` counts each
# connectivity fail as w failures; ``"auto"`` derives the smallest w that
# makes severing net-negative from the value rates themselves.
w = self._connectivity_weight
if w != 1.0:
n_conn = sum(1 for f in failures if is_connectivity_fail(f))
n_other = len(failures) - n_conn
value *= 0.5 ** (n_other + w * n_conn)
else:
value *= 0.5 ** len(failures)
score = value / cost if cost != 0.0 else 0.0

View file

@ -460,6 +460,80 @@ def mutate_bridge_circulation(root: dom.Node, rng: np.random.Generator,
return _finalise(child), f"bridge_circulation lvl{li}: {names} -> C"
def repair_circulation_settled(lvl: dom.Node, reqs, max_bridges: int = 8) -> int:
"""Reconnect a storey's circulation AFTER the geometry has settled.
homemaker-py-yql (DESIGN.md §39.9). ``_assign_adjacency_aware`` picks
circulation as a CONNECTED dominating set, but it does so against the
pre-resize geometry; ``_size_divisions_from_targets`` then moves every wall
to hit the programme's area targets and the shared boundaries the dominating
set relied on shrink below ``door_width`` or vanish outright. Measured on
health-centre: 41 of 49 circulation-to-circulation edges destroyed by the
resize, surviving shared walls squeezed to 0.54-1.11 m against a 1.2 m
threshold so only 5% of constructed seeds started connected, against 100%
with the resize disabled.
This is the same alternating-minimisation fix §37.7 applied to room
assignment (``_cpsat_relabel_settled``): re-run the step against the
geometry that actually resulted. Retypes the cheapest bridging leaves to
``C``, preferring generic outside, then unassigned, and crossing a required
room last the cost model ``mutate_bridge_circulation`` already uses.
Returns the number of leaves retyped. Idempotent once connected.
"""
import networkx as nx
from . import geometry as _geo, graph as _graph
def _cost(node: dom.Node) -> int:
if dom.is_circulation(node):
return 0
if not node.type:
return 1
if node.type in dom.GENERIC_OUTSIDE:
return 0
if reqs and node.type in reqs:
return 5
return 1
retyped = 0
for _ in range(max_bridges):
_geo.clear_cache()
G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH)
circ = [x for x in G.nodes() if dom.is_circulation(x)]
if not circ:
return retyped
comps = list(nx.connected_components(G.subgraph(circ)))
if len(comps) <= 1:
return retyped
weighted = G.copy()
for u, v, data in weighted.edges(data=True):
data["bridge_weight"] = (_cost(u) + _cost(v)) / 2.0
best_path = None
best_weight = None
for i in range(len(comps)):
for j in range(i + 1, len(comps)):
for a in comps[i]:
for b in comps[j]:
try:
path = nx.shortest_path(weighted, a, b,
weight="bridge_weight")
except nx.NetworkXNoPath:
continue
w = sum(_cost(x) for x in path[1:-1])
if best_weight is None or w < best_weight:
best_weight, best_path = w, path
if not best_path:
return retyped
middle = [x for x in best_path[1:-1] if not dom.is_circulation(x)]
if not middle:
return retyped # components already touch; nothing to retype
for leaf in middle:
leaf.type = "C"
retyped += 1
return retyped
def _shape_failing(leaf: dom.Node, fit) -> bool:
"""A named-room leaf whose width or proportion factor actually fails
(``< fitness.FAIL_THRESHOLD``) under ``fit``, the same Gaussian quality
@ -802,6 +876,129 @@ def _size_divisions_from_targets(lvl: dom.Node, reqs, fmin: float = 0.04,
geometry.clear_cache()
def _circ_components(lvl: dom.Node) -> int:
"""Number of connected components among this storey's circulation leaves."""
import networkx as nx
from . import geometry as _geo, graph as _graph
_geo.clear_cache()
G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH)
circ = [x for x in G.nodes() if dom.is_circulation(x)]
if not circ:
return 0
return nx.number_connected_components(G.subgraph(circ))
def _circ_edges(lvl: dom.Node) -> list[tuple]:
"""Circulation-to-circulation adjacencies on this storey, as leaf pairs."""
from . import geometry as _geo, graph as _graph
_geo.clear_cache()
G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH)
return [(a, b) for a, b in G.edges()
if dom.is_circulation(a) and dom.is_circulation(b)]
def _circ_edge_absent(lvl: dom.Node, a: dom.Node, b: dom.Node) -> bool:
from . import geometry as _geo, graph as _graph
_geo.clear_cache()
G = _geo.leaf_graph(lvl, _graph.DOOR_WIDTH)
return not (G.has_node(a) and G.has_node(b) and G.has_edge(a, b))
def _size_divisions_preserving_circulation(lvl: dom.Node, reqs,
max_reverts: int = 12, **kw) -> int:
"""Resize toward the programme's area targets WITHOUT severing circulation.
homemaker-py-3z0 (DESIGN.md §39.10). ``_assign_adjacency_aware`` picks
circulation as a CONNECTED dominating set, then
``_size_divisions_from_targets`` re-cuts every node new ratio *and* new
rotation and the shared boundaries the dominating set relied on shrink
below ``door_width`` or vanish. Measured (§39.9): fully-connected constructed
seeds 1/20, 1/20, 0/20 across the corpus, against 100% with the resize
skipped entirely. Both halves of the re-cut do damage, in different
proportions per programme on health-centre it is entirely the ratio, on
maple-court mostly the rotation so a fix has to be able to give back
either.
Rather than rebuild the connection afterwards by retyping rooms to ``C``
(measured a net loss it displaces required rooms at a 3-5 fail cascade
each, §39.9), this gives back the *geometry* and keeps the programme intact:
snapshot every cut, resize, then greedily revert whichever single cut most
reduces the circulation component count until the storey is connected again.
Reverting a cut costs only the area-target accuracy of that one subtree, and
the inner loop optimises ratios anyway nothing is displaced and no label
changes.
Returns the number of cuts reverted.
"""
from . import geometry as _geo
nodes = []
def _walk(node: dom.Node) -> None:
if node.divided:
nodes.append(node)
_walk(node.left)
_walk(node.right)
_walk(lvl)
before = {id(x): (x.rotation, list(x.division) if x.division else None)
for x in nodes}
_pre_circ_edges = _circ_edges(lvl)
_size_divisions_from_targets(lvl, reqs, **kw)
if _circ_components(lvl) <= 1:
_geo.clear_cache()
return 0
# Which circulation pairs were adjacent BEFORE the re-cut and are not now?
# Those are the connections the resize broke, and the cuts that govern each
# are exactly the ones between the two leaves — so revert those, rather than
# hunting for a single cut that happens to reduce the component count. A
# plain greedy gets stuck: often no ONE revert helps even though two would.
def _paths_between(a: dom.Node, b: dom.Node) -> list[dom.Node]:
"""Divided nodes on the tree path joining two leaves (via their LCA)."""
def _chain(x: dom.Node) -> list[dom.Node]:
out = []
while x is not None:
out.append(x)
x = x.parent
return out
ca, cb = _chain(a), _chain(b)
common = set(map(id, cb))
lca = next((x for x in ca if id(x) in common), None)
if lca is None:
return []
seen, out = set(), []
for chain in (ca, cb):
for x in chain:
if x.divided and id(x) not in seen:
out.append(x)
seen.add(id(x))
if x is lca:
break
return out
broken = [(a, b) for a, b in _pre_circ_edges
if _circ_edge_absent(lvl, a, b)]
reverted = 0
for a, b in broken:
if _circ_components(lvl) <= 1 or reverted >= max_reverts:
break
for node in _paths_between(a, b):
rot, div = before.get(id(node), (None, None))
if div is None or (node.rotation == rot and node.division == div):
continue
node.rotation, node.division = rot, list(div)
reverted += 1
_geo.clear_cache()
return reverted
def _grow_balanced(node: dom.Node, code: str, k: int) -> None:
"""Turn ``node`` (a leaf) into a balanced binary subtree of ``k`` leaves, all
typed ``code``. Split ratio/rotation are placeholders ([0.5,0.5], rot 0);
@ -1207,7 +1404,9 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
outside_divisor: int = 3,
construction_beam_width: int = 1,
multi_use: bool = False,
assign_solver: str = "greedy") -> dom.Node:
assign_solver: str = "greedy",
repair_circulation: bool = False,
preserve_circulation: bool = False) -> dom.Node:
"""Build a seed that instantiates every required space by construction.
The §11.0 diagnosis: random divide+retype chains leave required programme
@ -1312,11 +1511,14 @@ def constructive_topology(seed_root: dom.Node, reqs, rng: np.random.Generator,
leaf_co = _leaf_colocate_from_plan(lvl, colocate_plan, reqs) if multi_use else {}
leaf_extra = {lf: reqs[co].size for lf, co in leaf_co.items()
if co in reqs and reqs[co].size > 0}
_size_divisions_from_targets(
lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan),
_resize = (_size_divisions_preserving_circulation if preserve_circulation
else _size_divisions_from_targets)
_resize(lvl, reqs, leaf_mult=_leaf_mult_from_plan(lvl, share_plan),
leaf_extra=leaf_extra)
if adjacency_aware and assign_solver == "cpsat":
_cpsat_relabel_settled(lvl, reqs)
if repair_circulation:
repair_circulation_settled(lvl, reqs)
return _finalise(child)

View file

@ -104,6 +104,12 @@ TOILET_STRIPS = ("living", "kitchen", "toilet")
# Sociable rooms keep their MOST central circulation neighbour; terminal rooms
# and toilets keep their LEAST central one.
SOCIABLE_USAGES = ("living", "kitchen")
# Uses a person OCCUPIES, and which therefore want a window. Everything else --
# stores, toilets, plant, corridors, covered courtyards -- is ordinary buried
# architecture, and `crinkliness_mode="usage_daylight"` stops the objective
# demanding daylight for it (homemaker-py-ssz, DESIGN.md §38.8). A generic
# `C`/`O`/`S` leaf has no programme usage and is exempt for the same reason.
DAYLIGHT_USAGES = ("living", "kitchen", "bedroom")
def validate_codes(codes) -> None:

View file

@ -528,6 +528,93 @@ def test_crinkliness_exempt_circulation_only_exempts_circulation():
assert f.quality_uncrinkliness(room, None, {}) == 0.0
def test_crinkliness_compact_ok_scores_the_buried_limit_as_compact():
"""Regression (§38.8): a zero-exposure leaf IS the compact limit.
The first `compact_ok` returned the floor here, i.e. it announced that
being compact is not a defect and then punished the most compact case of
all hardest -- which is why it measured inert on buried leaves.
"""
f, leaf = _stub_fit("compact_ok", stub=0.0)
assert f.quality_uncrinkliness(leaf, None, {}) == 1.0
def _usage_fit(mode, stub, code, usage):
"""Stub Fitness carrying a one-space programme, so `usage_of` resolves."""
conf = dict(CONF_DEFAULTS)
conf["crinkliness_mode"] = mode
conf["spaces"] = {code: {"usage": usage, "size": [4.0, 1.0]}}
f = _StubCrink(conf, dict(COST_DEFAULTS))
f._stub = stub
return f, _leaf(code)
@pytest.mark.parametrize("usage", ["toilet", "utility", "none"])
def test_usage_daylight_exempts_uses_nobody_sits_in(usage):
"""A buried store or toilet is ordinary architecture, not a failure."""
f, leaf = _usage_fit("usage_daylight", 0.0, "x1", usage)
assert f.needs_daylight(leaf) is False
assert f.quality_uncrinkliness(leaf, None, {}) == 1.0
@pytest.mark.parametrize("usage", ["living", "kitchen", "bedroom"])
def test_usage_daylight_still_fails_a_windowless_habitable_room(usage):
"""The point of keying on usage: a bedroom with no daylight stays a hard
zero, exactly as stock. A mode that rescued this would be deleting the
fail category, not fixing the objective."""
f, leaf = _usage_fit("usage_daylight", 0.0, "x1", usage)
assert f.needs_daylight(leaf) is True
assert f.quality_uncrinkliness(leaf, None, {}) == 0.0
def test_usage_daylight_exempts_generic_types():
"""Generic `C`/`S` have no programme entry; a corridor needs no window."""
f, _ = _usage_fit("usage_daylight", 0.0, "x1", "living")
for code in ("C", "S"):
assert f.quality_uncrinkliness(_leaf(code), None, {}) == 1.0
def test_usage_daylight_still_punishes_over_exposure():
"""Exempt from needing daylight is not exempt from envelope cost: the
factor is clipped on the compact side only, never switched off."""
target = CONF_DEFAULTS["uncrinkliness"][0]
f, leaf = _usage_fit("usage_daylight", 1.0 / (target / 2), "x1", "utility")
assert f.quality_uncrinkliness(leaf, None, {}) < 1.0
def test_usage_daylight_leaves_stock_urb_untouched():
"""Same tree, mode off -> stock hard zero for every usage."""
for usage in ("living", "toilet", "none"):
f, leaf = _usage_fit("urb", 0.0, "x1", usage)
assert f.quality_uncrinkliness(leaf, None, {}) == 0.0
def test_crinkliness_mode_unknown_raises():
with pytest.raises(ValueError, match="crinkliness_mode"):
_stub_fit("nonsense")
# --------------------------------------------------------------------------- #
# homemaker-py-2v1 / DESIGN.md §39.8 — connectivity_weight (EXPERIMENTAL, NULL)
# --------------------------------------------------------------------------- #
def test_connectivity_weight_defaults_to_flat_rule():
"""Default must reproduce the flat 0.5^n penalty exactly."""
assert Fitness(conf={})._connectivity_weight == 1.0
def test_connectivity_weight_auto_is_derived_from_the_value_gap():
"""Not a magic number: the smallest w making 0.5^w < value_circulation /
value_inside, so it tracks the rates if either is retuned."""
from homemaker_layout.fitness import connectivity_weight_for
assert connectivity_weight_for(300.0, 50.0) == 3.0 # 0.5^3 < 1/6 < 0.5^2
assert connectivity_weight_for(100.0, 100.0) == 1.0 # no gap, no extra weight
assert connectivity_weight_for(400.0, 50.0) == 3.0 # 1/8 -> exactly 3
assert Fitness(conf={"connectivity_weight": "auto"})._connectivity_weight == 3.0
def test_is_connectivity_fail_matches_both_strings():
from homemaker_layout.fitness import is_connectivity_fail
assert is_connectivity_fail("level 0 not connected")
assert is_connectivity_fail("1 inaccessible usable space")
assert not is_connectivity_fail("0/llr crinkliness")
assert not is_connectivity_fail("missing required space: b1")

View file

@ -937,3 +937,89 @@ def test_assign_cpsat_beats_greedy_on_a_namespace_clean_programme():
return total
assert secondary_fails("cpsat") < secondary_fails("greedy")
# --------------------------------------------------------------------------- #
# homemaker-py-yql / DESIGN.md §39.9 — settled-geometry circulation repair
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_repair_circulation_default_off_reproduces_prior_seeds():
"""Default off must be byte-identical, like every other experimental flag."""
from homemaker_layout import programme
reqs = programme.load_programme_dir(str(HARBOR))
types = sorted(reqs) + ["C", "O"]
seed = dom.load(str(HARBOR / "init.dom"))
kw = dict(min_storeys=programme.storey_minimum(str(HARBOR)),
adjacency_aware=True, proportion_aware=True, circ_divisor=3)
def sig(**extra):
root = operators.constructive_topology(
seed, reqs, np.random.default_rng(3), types, **kw, **extra)
return tuple(lf.type for lvl in dom.levels(root) for lf in lvl.leaves())
assert sig() == sig(repair_circulation=False)
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_repair_circulation_reconnects_every_storey():
"""§39.9: the constructed circulation dominating set is connected, but
_size_divisions_from_targets then moves every wall and the shared
boundaries it relied on drop below door_width. Repairing against the
SETTLED geometry restores connectivity measured 52% -> 100% of levels on
harbor-house. (Whether that is a net WIN is a different question: it is
not, see §39.9 it displaces required rooms. Hence default off.)
"""
import networkx as nx
from homemaker_layout import geometry, graph as graph_mod, programme
reqs = programme.load_programme_dir(str(HARBOR))
types = sorted(reqs) + ["C", "O"]
seed = dom.load(str(HARBOR / "init.dom"))
def levels_connected(repair: bool) -> tuple[int, int]:
ok = tot = 0
for s in range(6):
root = operators.constructive_topology(
seed, reqs, np.random.default_rng(s), types,
min_storeys=programme.storey_minimum(str(HARBOR)),
adjacency_aware=True, proportion_aware=True, circ_divisor=3,
repair_circulation=repair)
for lvl in dom.levels(root):
geometry.clear_cache()
G = geometry.leaf_graph(lvl, graph_mod.DOOR_WIDTH)
circ = [n for n in G.nodes() if dom.is_circulation(n)]
tot += 1
if circ and nx.is_connected(G.subgraph(circ)):
ok += 1
return ok, tot
off_ok, off_tot = levels_connected(False)
on_ok, on_tot = levels_connected(True)
assert on_ok == on_tot, f"repair left {on_tot - on_ok} storeys disconnected"
assert on_ok > off_ok, f"repair did not help: {off_ok}/{off_tot} -> {on_ok}/{on_tot}"
@pytest.mark.skipif(not HARBOR.is_dir(), reason="harbor-house not available")
def test_preserve_circulation_default_off_reproduces_prior_seeds():
"""§39.10 measured NULL, so the default must stay byte-identical."""
from homemaker_layout import geometry, programme
reqs = programme.load_programme_dir(str(HARBOR))
types = sorted(reqs) + ["C", "O"]
seed = dom.load(str(HARBOR / "init.dom"))
kw = dict(min_storeys=programme.storey_minimum(str(HARBOR)),
adjacency_aware=True, proportion_aware=True, circ_divisor=3)
def sig(**extra):
geometry.clear_cache()
root = operators.constructive_topology(
seed, reqs, np.random.default_rng(5), types, **kw, **extra)
geometry.clear_cache()
return tuple((lf.type, round(geometry.area(lf), 6))
for lvl in dom.levels(root) for lf in lvl.leaves())
assert sig() == sig(preserve_circulation=False)
# ...and it does change something when enabled, or the A/B measured nothing
assert sig() != sig(preserve_circulation=True)