Remove the Perl oracle

Owner's decision: "we need to abandon the perl oracle, this was only useful
when initially porting, but I suspect many of the remaining problems have been
carried in from the perl (such as the weird scoring of outdoor and circulation
space, which definitely needs fixing)".

39 supports that second clause. Every defect the section found is inherited,
not introduced: the two-sided crinkliness gaussian that double-charges surplus
daylight (39.14), quality as a product over a variable number of factors
(39.18), value_supported priced as value_inside so a terrace was worth more per
m2 than a room (39.19), and circulation returning 0.07 per unit cost (hxi).

So parity with the oracle was never a safety net -- it was a commitment to
reproduce those defects. Each of 39.14, 39.18 and 39.19 would have been a
parity failure had parity ever been checked, and keeping the tests would have
meant reverting the fixes or explaining the failures away.

Removed: oracle.py, test_oracle.py, the two parity tests and their fixture
machinery in test_dom_corpus.py, innerloop.OracleEvaluator with its use_native
and urb_root plumbing, the same plumbing through driver, and fourteen
experiments/ scripts that could only run against Perl. Several of those are
cited in earlier DESIGN sections; the citations now point into git history,
which is the honest state -- they had been unrunnable since the oracle root
(/home/bruno/src/urb) stopped being present. run_search is superseded by
run_search_scaled, which does the same job natively.

Kept: dump_areas.pl/.py, which validate GEOMETRY against Urb (4.1) rather than
fitness, and the prose in fitness_cmd.py and dom.py explaining why the
.score/.fails formats are shaped as they are. Provenance is worth keeping; a
dead code path is not.

CLAUDE.md updated: fitness.py is the only evaluator, and "Urb did it this way"
is no longer an argument that a constant is right. 39.16 is the standing
counterweight in the other direction -- the crinkliness target WAS right and
twice looked wrong only because the code reading it was misunderstood.
Inheritance is neither evidence for nor against.

410 passed. The 69 removed cases account exactly: 64 parity (all skipped, since
no oracle .score was ever committed), 4 in test_oracle.py, and the guard test
39.20 added as a stopgap.

Closes homemaker-py-118. Files homemaker-py-bk9 for the re-baseline that 39.19
made necessary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
This commit is contained in:
Claude 2026-09-06 07:56:24 +00:00
parent f47dfe60af
commit d0567d7a74
No known key found for this signature in database
30 changed files with 126 additions and 2025 deletions

View file

@ -100,7 +100,6 @@ Key modules:
- `innerloop.py` — ratio optimisation inner loop (Nelder-Mead / CMA-ES) - `innerloop.py` — ratio optimisation inner loop (Nelder-Mead / CMA-ES)
- `driver.py` — memetic search outer loop - `driver.py` — memetic search outer loop
- `evolve.py``homemaker-evolve` CLI entry point - `evolve.py``homemaker-evolve` CLI entry point
- `oracle.py` — legacy Perl shim, kept for validation only; do not use in new code
- `bubble.py` — 3D bubble-diagram adjacency fitness-signal prototype (DESIGN.md §27, `mi7`); validated NULL, not wired into `fitness.py` — reference only, do not build on without a new formulation - `bubble.py` — 3D bubble-diagram adjacency fitness-signal prototype (DESIGN.md §27, `mi7`); validated NULL, not wired into `fitness.py` — reference only, do not build on without a new formulation
## Conventions & Patterns ## Conventions & Patterns
@ -119,5 +118,14 @@ homemaker-fitness cf0b8a77e8b2325f92a7e7d150184a55.dom
The score is written to `<file>.dom.score` and failures to `<file>.dom.fails`; the numeric score is also printed to stderr. The score is written to `<file>.dom.score` and failures to `<file>.dom.fails`; the numeric score is also printed to stderr.
Do **not** use `urb-fitness.pl` directly — `oracle.py` and the Perl tool are `fitness.py` is the **only** evaluator. The Perl oracle it was ported from —
kept only for cross-validation. `oracle.py`, `urb-fitness.pl`, and the parity tests against them — is gone
(DESIGN.md §39.21). Those parity tests had never actually run: no oracle
`.score` was ever committed, so on a clean checkout every case skipped, and the
only cases that ever executed compared the native scorer with itself (§39.20).
The corollary matters when reading the objective: a constant or a rule that
looks odd is **not** thereby validated by "Urb did it this way". Several
defects found in §39 were carried straight over from the Perl — see §39.19 on
`value_supported`, and `homemaker-py-hxi` on circulation, which the owner has
ruled needs fixing.

View file

@ -7757,3 +7757,60 @@ misread constant and this all have the same shape: something was trusted
because it was *present*, not because it was *checked*. The fix each time is to because it was *present*, not because it was *checked*. The fix each time is to
make the artefact carry its own justification — a committed fixture, a stated make the artefact carry its own justification — a committed fixture, a stated
derivation, a naming convention that cannot admit the wrong file. derivation, a naming convention that cannot admit the wrong file.
### 39.21 The Perl oracle is gone (`homemaker-py-118`)
§39.20 found that the native-vs-Perl parity tests had never run and, where they
did run, were comparing the native scorer with itself. The owner's decision on
that is to remove the oracle outright: *we need to abandon the perl oracle,
this was only useful when initially porting, but I suspect many of the
remaining problems have been carried in from the perl (such as the weird
scoring of outdoor and circulation space, which definitely needs fixing).*
That second clause is the important one, and §39 supports it. Every defect this
section found is inherited, not introduced:
| defect | where it came from |
|---|---|
| §39.14 two-sided crinkliness gaussian, surplus daylight double-charged | Urb |
| §39.18 quality as a product over a variable number of factors | Urb |
| §39.19 `value_supported` = `value_inside`, terrace priced as a room | Urb |
| `hxi` circulation returning 0.07 per unit cost | Urb |
Parity with the oracle was therefore not a safety net; it was a **commitment to
reproduce those defects**. Every one of §39.14, §39.18 and §39.19 would have
been a parity failure had parity ever been checked. Keeping the tests would
have meant either reverting the fixes or explaining the failures away, and the
second is how a check becomes decorative.
**Removed:** `src/homemaker_layout/oracle.py`, `tests/test_oracle.py`, the two
parity tests and their fixture machinery in `tests/test_dom_corpus.py`,
`innerloop.OracleEvaluator` and its `use_native` / `urb_root` plumbing, the
same plumbing through `driver`, and fourteen `experiments/` scripts that could
only run against Perl — `accept_innerloop`, `bakeoff_innerloop`,
`bakeoff_native`, `bench_batch_oracle`, `benchmark_vs_urbevolve`,
`genome_parity`, `leaf_parity`, `operator_locality`, `optimize_fullfitness`,
`rebaseline_no_occlusion`, `refine_sweep`, `resolve_ratios`, `run_search`,
`sweep_failtypes`. Several are cited in earlier sections as the evidence for
their measurements; those citations now point into git history rather than the
working tree, which is the honest state — they had been unrunnable since the
oracle root (`/home/bruno/src/urb`) stopped being present. `run_search` is
superseded by `run_search_scaled`, which does the same job natively.
**Kept:** `dump_areas.pl`/`.py`, which validate *geometry* against Urb (§4.1)
rather than fitness, and the prose in `fitness_cmd.py` and `dom.py` explaining
why the `.score`/`.fails` formats are shaped as they are. Provenance is worth
keeping; a dead code path is not.
**What this changes about reading the objective.** "Urb did it this way" is no
longer an argument that a constant is right — but §39.16 is the standing
counterweight, and it cuts the other way: the crinkliness target *was* right,
and twice looked wrong only because the code reading it was misunderstood. The
test is now the same as for anything else. Does the number have a stated
derivation, does it survive measurement, and does the objective it belongs to
say what its author meant? Inheritance is neither evidence for nor against.
Next: `homemaker-py-hxi` — circulation at 0.07 return against a room's 0.66,
which the owner has ruled needs fixing — and the corpus re-baseline
(`homemaker-py-bk9`), which §39.19 made necessary and which every future number
depends on.

View file

@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""Acceptance run for the geometry inner loop (homemaker-py-1p0).
Gate (DESIGN.md §4.5, issue acceptance criteria): reproduce or exceed the
Nelder-Mead diagnostic gains x1.24 / x1.67 / x1.59, no new failures on
2f45907, candidate-002 and c964435, using the batched compass search.
Usage: accept_innerloop.py [budget] [method] (default: 200 oracle evals, cma)
"""
from __future__ import annotations
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, innerloop, oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples" / "programme-house"
# name -> reference x-gain. Current bars are the occlusion-disabled re-baseline
# (homemaker-py-gp2): the deterministic-seed CMA run at budget 400 under
# URB_NO_OCCLUSION=1 — set that env var to reproduce. (The original §4.5
# Nelder-Mead bars, flag-off, were 1.24 / 1.67 / 1.59; the inner loop met them
# within noise, homemaker-py-1p0.)
GATE = {
"2f45907abd9accac2a124d311732f749.dom": 1.63,
"candidate-002.dom": 1.70,
"c964435454c459f86c3ed9a5a7621132.dom": 1.68,
}
# Bars are single optimiser draws and per-run variance brackets them
# (candidate-002 drew 0.0117-0.0160 against a 0.0123 flag-off bar).
# Reproduction within 1% counts as met — decision approved 2026-06-12
# (homemaker-py-1p0); chasing the last fraction with seed rolls would be
# cherry-picking.
NOISE_TOL = 0.99
def main() -> int:
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 200
method = sys.argv[2] if len(sys.argv) > 2 else "cma"
print(f"method={method} budget={budget}")
all_ok = True
with tempfile.TemporaryDirectory(prefix="accept_innerloop_") as tmp:
scratch = Path(tmp)
shutil.copy(EX / "patterns.config", scratch)
for name, bar in GATE.items():
# Baseline = the UNMODIFIED original file, exactly as §4.5 measured
# it. The inner loop's own x0 score is the equal-offset *projection*
# of the original (a==b forced, clipped), which is lower for legacy
# designs with unequal cuts — gains must not be measured from there.
s_orig = oracle.score(shutil.copy(EX / name, scratch), URB)
root = dom.load(str(EX / name))
t0 = time.perf_counter()
r = innerloop.optimise(root, EX, budget=budget, method=method, urb_root=URB)
dt = time.perf_counter() - t0
gain = r.fitness / s_orig.fitness if s_orig.fitness else float("inf")
ok = gain >= bar * NOISE_TOL and r.n_fails <= s_orig.n_fails
all_ok &= ok
print(
f"{name:42s} dof={len(r.x):2d} "
f"orig={s_orig.fitness:.6g}(fails {s_orig.n_fails}) "
f"projected x0={r.x0_fitness:.6g}(fails {r.x0_n_fails}) "
f"opt={r.fitness:.6g}(fails {r.n_fails}) "
f"x{gain:.2f} (bar x{bar:.2f}) "
f"{r.n_evals} evals / {r.n_oracle_calls} oracle calls / {dt:.0f}s "
f"{'PASS' if ok else 'FAIL'}",
flush=True,
)
print("\nGATE " + ("PASSED" if all_ok else "FAILED"))
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,224 +0,0 @@
#!/usr/bin/env python3
"""Inner-loop optimiser bake-off at equal oracle budgets (homemaker-py-d0s).
DESIGN.md §7 Phase 1(b), §8.3. DOF is only rooms1 (67 on the corpus), so
the question is fitness gained per oracle evaluation, not asymptotic power.
Candidates:
nm multi-start Nelder-Mead (scipy) the §4.5 diagnostic optimiser.
Inherently sequential: ONE dom per oracle call, so the Perl
startup never amortises (§4.6).
cma multi-phase CMA-ES (innerloop.cma_search), one batched oracle
call per generation.
compass single-start batched compass search with pattern moves + random
augmentation (innerloop.compass_search).
compass-ms multi-start compass: budget split across restarts (x0 first,
then random starts), global best kept.
Protocol: cold start from each file's equal-offset projection (x_current),
one run per (method, file, seed), best-so-far recorded after every oracle
call. Fitness-at-budget-B is read off the trace (evals B), so methods are
compared at exactly equal budgets regardless of batch granularity; checkpoint
budgets bracket the driver's real operating points (child_budget=80 warm,
seed_budget=200 cold). Wall-clock and oracle-invocation counts are recorded
per run.
Runs under URB_NO_OCCLUSION=1 (set by this script the gp2 re-baseline flag;
all benchmarks must use it).
Usage: python3 experiments/bakeoff_innerloop.py [budget] [out.json]
(defaults: budget 200, experiments/bakeoff_innerloop.json)
"""
from __future__ import annotations
import json
import os
import shutil
import sys
import tempfile
import time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, innerloop, oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples" / "programme-house"
FILES = (
"2f45907abd9accac2a124d311732f749.dom",
"candidate-002.dom",
"c964435454c459f86c3ed9a5a7621132.dom",
)
SEEDS = (0, 1, 2)
CHECKPOINTS = (40, 80, 120, 200)
class TracingEvaluator(innerloop.OracleEvaluator):
"""Records (cumulative evals, batch-best fitness) after every oracle call."""
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.trace: list[tuple[int, float]] = []
def evaluate(self, xs):
scores = super().evaluate(xs)
self.trace.append((self.n_evals, max(s.fitness for s in scores)))
return scores
def best_at(self, budget: int) -> float:
vals = [f for n, f in self.trace if n <= budget]
return max(vals) if vals else float("nan")
class _BudgetExhausted(Exception):
pass
def nm_search(ev, x0, budget=200, seed=0):
"""Multi-start Nelder-Mead: x0 first, random restarts until the budget is
spent. Sequential every evaluation is its own oracle invocation."""
from scipy.optimize import minimize
rng = np.random.default_rng(seed)
n = len(x0)
x = np.clip(np.asarray(x0, dtype=float), innerloop._EPS, 1 - innerloop._EPS)
s = ev.evaluate([x])[0]
best = innerloop.Result(
x=x.copy(), fitness=s.fitness, n_fails=s.n_fails, fail_lines=s.fail_lines,
x0_fitness=s.fitness, x0_n_fails=s.n_fails, n_evals=0, n_oracle_calls=0,
)
def f(xi):
if ev.n_evals >= budget:
raise _BudgetExhausted
sc = ev.evaluate([np.asarray(xi, dtype=float)])[0]
if sc.fitness > best.fitness:
best.x = np.asarray(xi, dtype=float).copy()
best.fitness = sc.fitness
best.n_fails = sc.n_fails
best.fail_lines = sc.fail_lines
return -sc.fitness
start = x.copy()
while ev.n_evals < budget:
try:
minimize(
f, start, method="Nelder-Mead",
bounds=[(innerloop._EPS, 1 - innerloop._EPS)] * n,
options={"maxfev": budget - ev.n_evals, "xatol": 1e-3, "fatol": 1e-10},
)
except _BudgetExhausted:
break
start = rng.uniform(0.1, 0.9, n) # restart
best.n_evals = ev.n_evals
best.n_oracle_calls = ev.n_oracle_calls
return best
def compass_ms_search(ev, x0, budget=200, seed=0, n_starts=3):
"""Multi-start compass: budget split evenly; first start is x0, the rest
random. compass_search counts against ev.n_evals, so phase budgets are
cumulative caps."""
rng = np.random.default_rng(seed)
n = len(x0)
best = None
for phase in range(n_starts):
phase_end = ev.n_evals + (budget - ev.n_evals) // (n_starts - phase)
start = np.asarray(x0, dtype=float) if phase == 0 else rng.uniform(0.1, 0.9, n)
r = innerloop.compass_search(ev, start, budget=phase_end, seed=seed + phase)
if best is None or r.fitness > best.fitness:
keep_x0 = best.x0_fitness if best is not None else r.x0_fitness
keep_x0f = best.x0_n_fails if best is not None else r.x0_n_fails
best = r
best.x0_fitness, best.x0_n_fails = keep_x0, keep_x0f
if ev.n_evals >= budget:
break
best.n_evals = ev.n_evals
best.n_oracle_calls = ev.n_oracle_calls
return best
METHODS = {
"nm": nm_search,
"cma": innerloop.cma_search,
"compass": innerloop.compass_search,
"compass-ms": compass_ms_search,
}
def main() -> int:
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 200
out_path = Path(sys.argv[2]) if len(sys.argv) > 2 else (
Path(__file__).parent / "bakeoff_innerloop.json")
os.environ["URB_NO_OCCLUSION"] = "1"
checkpoints = [c for c in CHECKPOINTS if c <= budget] or [budget]
if checkpoints[-1] != budget:
checkpoints.append(budget)
# Baselines: the UNMODIFIED originals (gains measured from there, not from
# the equal-offset projection — accept_innerloop.py convention).
orig: dict[str, oracle.Score] = {}
with tempfile.TemporaryDirectory(prefix="bakeoff_orig_") as tmp:
shutil.copy(EX / "patterns.config", tmp)
for name in FILES:
orig[name] = oracle.score(shutil.copy(EX / name, tmp), URB)
runs = []
for name in FILES:
for method in METHODS:
for seed in SEEDS:
root = dom.load(str(EX / name))
with TracingEvaluator(root, EX, URB) as ev:
x0 = ev.x_current
t0 = time.perf_counter()
r = METHODS[method](ev, x0, budget=budget, seed=seed)
dt = time.perf_counter() - t0
run = {
"file": name, "method": method, "seed": seed,
"dof": len(x0),
"orig_fitness": orig[name].fitness,
"orig_n_fails": orig[name].n_fails,
"x0_fitness": r.x0_fitness, "x0_n_fails": r.x0_n_fails,
"best_at": {str(c): ev.best_at(c) for c in checkpoints},
"final_fitness": r.fitness, "final_n_fails": r.n_fails,
"n_evals": ev.n_evals, "n_oracle_calls": ev.n_oracle_calls,
"wall_s": dt,
}
runs.append(run)
gains = " ".join(
f"@{c}:x{run['best_at'][str(c)] / orig[name].fitness:.2f}"
for c in checkpoints)
print(
f"{name[:12]:12s} {method:10s} seed={seed} {gains} "
f"fails {orig[name].n_fails}->{r.n_fails} "
f"{ev.n_evals}ev/{ev.n_oracle_calls}calls {dt:.0f}s",
flush=True,
)
out_path.write_text(json.dumps(
{"budget": budget, "checkpoints": checkpoints, "runs": runs}, indent=1))
print(f"\nwrote {out_path}")
# Summary: mean gain over original at each checkpoint, mean s/eval.
print(f"\n{'method':10s} " + "".join(f"{'x@' + str(c):>8s}" for c in checkpoints)
+ f"{'s/eval':>8s}{'calls':>7s}{'fails+':>7s}")
for method in METHODS:
rs = [r for r in runs if r["method"] == method]
cols = ""
for c in checkpoints:
g = np.mean([r["best_at"][str(c)] / r["orig_fitness"] for r in rs])
cols += f"{g:8.2f}"
spe = np.mean([r["wall_s"] / r["n_evals"] for r in rs])
calls = np.mean([r["n_oracle_calls"] for r in rs])
newf = sum(r["final_n_fails"] > r["orig_n_fails"] for r in rs)
print(f"{method:10s} {cols}{spe:8.2f}{calls:7.0f}{newf:7d}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,207 +0,0 @@
#!/usr/bin/env python3
"""Inner-loop optimiser bake-off with native fitness (homemaker-py-d6d).
Re-runs the Phase 1 oracle bakeoff (bakeoff_innerloop.py / homemaker-py-d0s)
using the native Python fitness evaluator instead of the Perl oracle. The
oracle's 1 s/eval startup cost previously penalised sequential methods (NM);
with native fitness at ~70 evals/s that constraint is gone.
Candidates: nm, cma, compass, compass-ms (same as Phase 1).
Protocol: cold start from each file's equal-offset projection, one run per
(method, file, seed), best-so-far traced after every eval. Gains measured
relative to native baseline (native score of the unmodified original).
Usage: python3 experiments/bakeoff_native.py [budget] [out.json]
(defaults: budget 200, experiments/bakeoff_native.json)
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, fitness as fit_mod, innerloop, solver # noqa: E402
EX = Path("/home/bruno/src/urb/examples/programme-house")
FILES = (
"2f45907abd9accac2a124d311732f749.dom",
"candidate-002.dom",
"c964435454c459f86c3ed9a5a7621132.dom",
)
SEEDS = (0, 1, 2)
CHECKPOINTS = (40, 80, 120, 200)
class NativeTracingEvaluator(innerloop.NativeEvaluator):
"""NativeEvaluator that records (cumulative evals, batch-best fitness)."""
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.trace: list[tuple[int, float]] = []
def evaluate(self, xs):
scores = super().evaluate(xs)
self.trace.append((self.n_evals, max(s.fitness for s in scores)))
return scores
def best_at(self, budget: int) -> float:
vals = [f for n, f in self.trace if n <= budget]
return max(vals) if vals else float("nan")
class _BudgetExhausted(Exception):
pass
def nm_search(ev, x0, budget=200, seed=0):
"""Multi-start Nelder-Mead: x0 first, random restarts until budget spent."""
from scipy.optimize import minimize
rng = np.random.default_rng(seed)
n = len(x0)
x = np.clip(np.asarray(x0, dtype=float), innerloop._EPS, 1 - innerloop._EPS)
s = ev.evaluate([x])[0]
best = innerloop.Result(
x=x.copy(), fitness=s.fitness, n_fails=s.n_fails, fail_lines=s.fail_lines,
x0_fitness=s.fitness, x0_n_fails=s.n_fails, n_evals=0, n_oracle_calls=0,
)
def f(xi):
if ev.n_evals >= budget:
raise _BudgetExhausted
sc = ev.evaluate([np.asarray(xi, dtype=float)])[0]
if sc.fitness > best.fitness:
best.x = np.asarray(xi, dtype=float).copy()
best.fitness = sc.fitness
best.n_fails = sc.n_fails
best.fail_lines = sc.fail_lines
return -sc.fitness
start = x.copy()
while ev.n_evals < budget:
try:
minimize(
f, start, method="Nelder-Mead",
bounds=[(innerloop._EPS, 1 - innerloop._EPS)] * n,
options={"maxfev": budget - ev.n_evals, "xatol": 1e-3, "fatol": 1e-10},
)
except _BudgetExhausted:
break
start = rng.uniform(0.1, 0.9, n)
best.n_evals = ev.n_evals
best.n_oracle_calls = ev.n_oracle_calls
return best
def compass_ms_search(ev, x0, budget=200, seed=0, n_starts=3):
"""Multi-start compass: budget split evenly; first start is x0."""
rng = np.random.default_rng(seed)
n = len(x0)
best = None
for phase in range(n_starts):
phase_end = ev.n_evals + (budget - ev.n_evals) // (n_starts - phase)
start = np.asarray(x0, dtype=float) if phase == 0 else rng.uniform(0.1, 0.9, n)
r = innerloop.compass_search(ev, start, budget=phase_end, seed=seed + phase)
if best is None or r.fitness > best.fitness:
keep_x0 = best.x0_fitness if best is not None else r.x0_fitness
keep_x0f = best.x0_n_fails if best is not None else r.x0_n_fails
best = r
best.x0_fitness, best.x0_n_fails = keep_x0, keep_x0f
if ev.n_evals >= budget:
break
best.n_evals = ev.n_evals
best.n_oracle_calls = ev.n_oracle_calls
return best
METHODS = {
"nm": nm_search,
"cma": innerloop.cma_search,
"compass": innerloop.compass_search,
"compass-ms": compass_ms_search,
}
def native_baseline(path: Path) -> innerloop._NativeScore:
"""Native fitness of the unmodified file."""
root = dom.load(str(path))
conf, cost = fit_mod.load_config(EX)
fit = fit_mod.Fitness(conf, cost)
score, fails = fit.score_with_fails(root)
return innerloop._NativeScore(fitness=score, fail_lines=tuple(fails))
def main() -> int:
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 200
out_path = Path(sys.argv[2]) if len(sys.argv) > 2 else (
Path(__file__).parent / "bakeoff_native.json")
os.environ["URB_NO_OCCLUSION"] = "1"
checkpoints = [c for c in CHECKPOINTS if c <= budget] or [budget]
if checkpoints[-1] != budget:
checkpoints.append(budget)
orig: dict[str, innerloop._NativeScore] = {}
for name in FILES:
orig[name] = native_baseline(EX / name)
runs = []
for name in FILES:
for method in METHODS:
for seed in SEEDS:
root = dom.load(str(EX / name))
ev = NativeTracingEvaluator(root, EX)
x0 = ev.x_current
t0 = time.perf_counter()
r = METHODS[method](ev, x0, budget=budget, seed=seed)
dt = time.perf_counter() - t0
run = {
"file": name, "method": method, "seed": seed,
"dof": len(x0),
"orig_fitness": orig[name].fitness,
"orig_n_fails": orig[name].n_fails,
"x0_fitness": r.x0_fitness, "x0_n_fails": r.x0_n_fails,
"best_at": {str(c): ev.best_at(c) for c in checkpoints},
"final_fitness": r.fitness, "final_n_fails": r.n_fails,
"n_evals": ev.n_evals, "n_oracle_calls": ev.n_oracle_calls,
"wall_s": dt,
}
runs.append(run)
gains = " ".join(
f"@{c}:x{run['best_at'][str(c)] / orig[name].fitness:.2f}"
for c in checkpoints)
print(
f"{name[:12]:12s} {method:10s} seed={seed} {gains} "
f"fails {orig[name].n_fails}->{r.n_fails} "
f"{ev.n_evals}ev {dt:.1f}s",
flush=True,
)
out_path.write_text(json.dumps(
{"budget": budget, "checkpoints": checkpoints, "runs": runs}, indent=1))
print(f"\nwrote {out_path}")
print(f"\n{'method':10s} " + "".join(f"{'x@' + str(c):>8s}" for c in checkpoints)
+ f"{'s/eval':>8s}{'fails+':>7s}")
for method in METHODS:
rs = [r for r in runs if r["method"] == method]
cols = ""
for c in checkpoints:
g = np.mean([r["best_at"][str(c)] / r["orig_fitness"] for r in rs])
cols += f"{g:8.3f}"
spe = np.mean([r["wall_s"] / r["n_evals"] for r in rs])
newf = sum(r["final_n_fails"] > r["orig_n_fails"] for r in rs)
print(f"{method:10s} {cols}{spe:8.4f}{newf:7d}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,67 +0,0 @@
#!/usr/bin/env python3
"""Validate and benchmark the batched oracle (homemaker-py-av5).
Copies the 35-file programme-house corpus into a scratch directory, scores
every file via single-file oracle calls and again via one batched invocation,
then checks the per-file fitness and failure sets are identical and reports
measured s/dom for both modes.
"""
from __future__ import annotations
import math
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
CORPUS = URB / "examples" / "programme-house"
def main() -> int:
with tempfile.TemporaryDirectory(prefix="bench_batch_") as tmp:
scratch = Path(tmp)
shutil.copy(CORPUS / "patterns.config", scratch)
doms = sorted(CORPUS.glob("*.dom"))
paths = [shutil.copy(d, scratch) for d in doms]
paths = [Path(p) for p in paths]
print(f"{len(paths)} corpus files -> {scratch}")
t0 = time.perf_counter()
singles = [oracle.score(p, URB) for p in paths]
t_single = time.perf_counter() - t0
t0 = time.perf_counter()
batch = oracle.score_batch(paths, URB)
t_batch = time.perf_counter() - t0
# Urb's score is nondeterministic at the ~1 ULP level (Perl hash-order
# summation), so compare fitness with a tight relative tolerance; any
# real semantic difference (e.g. occlusion handling) would be far larger.
mismatches = 0
for p, s, b in zip(paths, singles, batch):
if not math.isclose(s.fitness, b.fitness, rel_tol=1e-12) or s.fail_lines != b.fail_lines:
mismatches += 1
print(f"MISMATCH {p.name}: single {s.fitness:.12g} ({s.n_fails} fails) "
f"vs batch {b.fitness:.12g} ({b.n_fails} fails)")
print(f" single-only: {sorted(set(s.fail_lines) - set(b.fail_lines))}")
print(f" batch-only: {sorted(set(b.fail_lines) - set(s.fail_lines))}")
n = len(paths)
print(f"\nsingle-file: {t_single:.2f} s total, {t_single / n:.3f} s/dom")
print(f"batched: {t_batch:.2f} s total, {t_batch / n:.3f} s/dom")
print(f"speedup: x{t_single / t_batch:.2f}")
if mismatches:
print(f"\nFAIL: {mismatches}/{n} files differ between single and batch")
return 1
print(f"\nOK: all {n} files identical (fitness to 1e-12 rel, exact failure set) in both modes")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,133 +0,0 @@
#!/usr/bin/env python3
"""Phase-2 gate: memetic loop vs urb-evolve at equal oracle-eval budgets
(homemaker-py-way).
Protocol: 3 seed designs x 2000-evaluation runs; the 1000-eval tier is read
from each run's own best-so-far log (no extra oracle time). urb-evolve runs
at two population sizes (its default 128, which only allows ~16 generations
at this budget, and 16, which allows ~130) and gets credit for its better
one. urb-evolve counts evaluations via the MAX_EVALS patch; the memetic
driver accounts natively. Both run under URB_NO_OCCLUSION=1 with the same
patterns.config; every final design is re-scored through urb-fitness.pl as
the common deterministic yardstick.
URB_NO_OCCLUSION=1 python3 experiments/benchmark_vs_urbevolve.py [budget]
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples" / "programme-house"
HERE = Path(__file__).resolve().parent
WORK = HERE.parent / "scratch" / "benchmark"
SEEDS = ["init.dom", "c964435454c459f86c3ed9a5a7621132.dom",
"2f45907abd9accac2a124d311732f749.dom"]
CHECKPOINT_FRACTION = 0.5
def run_homemaker(seed: str, budget: int, cell: Path) -> dict:
out = cell / "best.dom"
proc = subprocess.run(
[sys.executable, str(HERE / "run_search.py"), str(budget), "0",
str(EX / seed), str(out)],
capture_output=True, text=True, env={**os.environ, "URB_NO_OCCLUSION": "1"},
)
(cell / "run.log").write_text(proc.stdout + proc.stderr)
series = [(int(m[1]), float(m[2])) for m in
re.finditer(r"\[\s*(\d+) evals\] best ([\d.e-]+)", proc.stdout)]
return {"series": series, "best_dom": out, "log_ok": proc.returncode == 0}
def run_urbevolve(seed: str, budget: int, cell: Path, pop: int) -> dict:
shutil.copy(EX / "patterns.config", cell)
shutil.copy(EX / seed, cell)
proc = subprocess.run(
["perl", f"-I{URB}/lib", str(URB / "bin" / "urb-evolve.pl"), seed],
cwd=cell, capture_output=True, text=True,
env={**os.environ, "URB_NO_OCCLUSION": "1", "MAX_EVALS": str(budget),
"MAX_POP": str(pop), "MAX_ITERATIONS": "100000"},
)
(cell / "run.log").write_text(proc.stderr)
series = []
evals = None
for line in proc.stderr.splitlines():
if m := re.search(r"Evals: (\d+)", line):
evals = int(m[1])
elif (m := re.search(r"Fitness: ([\d.e-]+)", line)) and evals is not None:
series.append((evals, float(m[1])))
out_hash = proc.stdout.strip().split()[-1] if proc.stdout.strip() else None
best = cell / f"{out_hash}.dom" if out_hash else None
return {"series": series, "best_dom": best,
"log_ok": proc.returncode == 0 and best is not None and best.exists()}
def best_at(series: list[tuple[int, float]], budget: int) -> float:
vals = [f for e, f in series if e <= budget]
return max(vals) if vals else float("nan")
def main() -> int:
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 2000
checkpoint = int(budget * CHECKPOINT_FRACTION)
if WORK.exists():
shutil.rmtree(WORK)
cells = []
for seed in SEEDS:
cells.append((seed, "memetic", run_homemaker, {}))
cells.append((seed, "urb-evolve p16", run_urbevolve, {"pop": 16}))
cells.append((seed, "urb-evolve p128", run_urbevolve, {"pop": 128}))
def run_cell(args):
seed, label, fn, kw = args
cell = WORK / f"{seed.split('.')[0][:8]}_{label.replace(' ', '_')}"
cell.mkdir(parents=True, exist_ok=True)
print(f"start: {seed} / {label}", flush=True)
res = fn(seed, budget, cell, **kw)
print(f"done: {seed} / {label} ({len(res['series'])} log points)", flush=True)
return (seed, label), res
with ThreadPoolExecutor(max_workers=3) as pool:
results = dict(pool.map(run_cell, cells))
# common yardstick: re-score every final design through urb-fitness.pl
for key, res in results.items():
if res["best_dom"] and Path(res["best_dom"]).exists():
s = oracle.score(res["best_dom"], URB)
res["final"], res["fails"] = s.fitness, s.n_fails
else:
res["final"], res["fails"] = float("nan"), -1
print(f"\n{'seed':10s} {'system':16s} {'best@' + str(checkpoint):>12s} "
f"{'final@' + str(budget):>12s} {'fails':>6s}")
verdicts = []
for seed in SEEDS:
rows = {label: results[(seed, label)] for label in
("memetic", "urb-evolve p16", "urb-evolve p128")}
for label, res in rows.items():
print(f"{seed[:10]:10s} {label:16s} {best_at(res['series'], checkpoint):12.6g} "
f"{res['final']:12.6g} {res['fails']:6d}")
hm = rows["memetic"]["final"]
urb = max(rows["urb-evolve p16"]["final"], rows["urb-evolve p128"]["final"])
verdicts.append(hm > urb)
print(f"{'':10s} -> memetic {'BEATS' if hm > urb else 'LOSES TO'} "
f"urb-evolve ({hm:.6g} vs {urb:.6g})")
print(f"\nGATE: memetic wins {sum(verdicts)}/{len(verdicts)} seeds "
f"-> {'GO' if all(verdicts) else 'REVIEW'}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -21,7 +21,6 @@ from scipy.optimize import minimize
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, innerloop # noqa: E402 from homemaker_layout import dom, innerloop # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples" / "programme-house" EX = URB / "examples" / "programme-house"
NAME = "2f45907abd9accac2a124d311732f749.dom" NAME = "2f45907abd9accac2a124d311732f749.dom"
@ -52,7 +51,7 @@ def main() -> None:
f"({ev.n_evals - n0} evals)", flush=True) f"({ev.n_evals - n0} evals)", flush=True)
root = dom.load(str(EX / NAME)) root = dom.load(str(EX / NAME))
r = innerloop.optimise(root, EX, budget=200, method="cma", sigmas=(0.05,), urb_root=URB) r = innerloop.optimise(root, EX, budget=200, method="cma", sigmas=(0.05,))
print(f"CMA sigma 0.05: {r.fitness:.6g} fails {r.n_fails} ({r.n_evals} evals)") print(f"CMA sigma 0.05: {r.fitness:.6g} fails {r.n_fails} ({r.n_evals} evals)")

View file

@ -129,7 +129,7 @@ def _measure(fit, pdir, seed_root, reqs, types, s, balanced, sharing, factor):
after_tree = copy.deepcopy(topo) after_tree = copy.deepcopy(topo)
with _force_sharing(sharing): with _force_sharing(sharing):
innerloop.optimise(after_tree, str(pdir), x0=None, budget=BUDGET, innerloop.optimise(after_tree, str(pdir), x0=None, budget=BUDGET,
method="nm", use_native=True) method="nm")
_s2, fails2 = fit.score_with_fails(copy.deepcopy(after_tree)) _s2, fails2 = fit.score_with_fails(copy.deepcopy(after_tree))
after = {"n_leaves": n_leaves, "total": len(fails2), **_bucket(fails2), after = {"n_leaves": n_leaves, "total": len(fails2), **_bucket(fails2),
**_maldist(after_tree, fit, reqs)} **_maldist(after_tree, fit, reqs)}

View file

@ -105,7 +105,7 @@ def _measure(fit, pdir, seed_root, reqs, types, s, sharing, factor):
after_tree = copy.deepcopy(topo) after_tree = copy.deepcopy(topo)
with _force_sharing(sharing): with _force_sharing(sharing):
innerloop.optimise(after_tree, str(pdir), x0=None, budget=BUDGET, innerloop.optimise(after_tree, str(pdir), x0=None, budget=BUDGET,
method="nm", use_native=True) method="nm")
_s2, fails2 = fit.score_with_fails(copy.deepcopy(after_tree)) _s2, fails2 = fit.score_with_fails(copy.deepcopy(after_tree))
after = {"n_leaves": n_leaves, "total": len(fails2), **_bucket(fails2)} after = {"n_leaves": n_leaves, "total": len(fails2), **_bucket(fails2)}
return before, after return before, after

View file

@ -134,7 +134,7 @@ def _run_seed(pdir, fit, reqs, types, seed_root, s):
after_tree = copy.deepcopy(topo) after_tree = copy.deepcopy(topo)
r = innerloop.optimise(after_tree, str(pdir), x0=None, budget=BUDGET, r = innerloop.optimise(after_tree, str(pdir), x0=None, budget=BUDGET,
method="nm", use_native=True) method="nm")
after = _measure(after_tree, fit, reqs) after = _measure(after_tree, fit, reqs)
after["n_evals"] = r.n_evals after["n_evals"] = r.n_evals
return before, after, _leaf_ratios(topo, fit, reqs) return before, after, _leaf_ratios(topo, fit, reqs)

View file

@ -1,99 +0,0 @@
#!/usr/bin/perl
# Parity oracle for homemaker-py-gnw: per-leaf quality factors and per-storey
# cost/value from Urb's programme-driven fitness.
#
# Stock urb-fitness.pl cannot emit the per-leaf DEBUG lines: Leaf.pm/Storey.pm
# copy $Urb::Dom::Fitness::DEBUG into a package var at *compile* time, before
# Fitness.pm's own `our $DEBUG = $ENV{DEBUG}` line has run, so the flag is
# permanently false. This wrapper sets those package vars after loading and
# also wraps process_storey to print each storey's cost/value subtotal.
#
# Usage (from the corpus directory, URB_NO_OCCLUSION required):
# cd examples/programme-house
# URB_NO_OCCLUSION=1 perl -I$URB/lib dump_leaf_quality.pl a.dom b.dom ...
#
# Output (stdout):
# === FILE <name>
# ...Leaf.pm debug lines (' quality perpendicular: <f>' x7, then
# 'leaf level: L id: I type: T rate: R area: A width: W proportion: P')
# STOREY <level_id> cost: <c> value: <v>
# INITIAL_COST <c>
# FAIL <message> (one per failure)
# SCORE <fitness>
use strict;
use warnings;
use 5.010;
use Urb::Dom;
use Urb::Dom::Fitness;
use Urb::Dom::Fitness::ProgrammeDriven;
use YAML;
use Carp;
die "URB_NO_OCCLUSION=1 required: native fitness ports simple crinkliness only\n"
unless $ENV{URB_NO_OCCLUSION};
# Re-enable the per-leaf/storey debug gates (see header) and route everything
# to STDOUT so output interleaves deterministically.
$Urb::Dom::Fitness::Leaf::DEBUG = 1;
$Urb::Dom::Fitness::Storey::DEBUG = 1;
$Urb::Dom::Fitness::DEBUG = 1;
{
no warnings 'redefine';
*Urb::Dom::Fitness::debug = sub { print join(' ', @_), "\n"; return 1 };
*Urb::Dom::Fitness::Base::debug = sub {
my @text = @_;
shift @text if ref $text[0]; # method form: drop $self
print join(' ', @text), "\n";
return 1;
};
}
my $orig_process_storey = \&Urb::Dom::Fitness::Storey::process_storey;
{
no warnings 'redefine';
*Urb::Dom::Fitness::Storey::process_storey = sub {
my @args = @_;
my $result = $orig_process_storey->(@args);
printf "STOREY %s cost: %.17g value: %.17g\n",
$args[5], $result->{cost}, $result->{value};
return $result;
};
}
# Config loading exactly as urb-fitness.pl (project-level then local).
my $config = undef;
for my $path ('../patterns.config', 'patterns.config')
{
next unless -e $path;
my $config_temp = YAML::LoadFile ($path);
$config->{$_} = $config_temp->{$_} for keys %{$config_temp};
}
my $costs = undef;
for my $path ('../costs.config', 'costs.config')
{
next unless -e $path;
my $costs_temp = YAML::LoadFile ($path);
$costs->{$_} = $costs_temp->{$_} for keys %{$costs_temp};
}
die "no spaces config: wrapper only supports programme-driven mode\n"
unless $config && exists $config->{spaces};
my $assessor = Urb::Dom::Fitness::ProgrammeDriven->new ($config, $costs);
for my $path_yaml (@ARGV)
{
my @items = YAML::LoadFile ($path_yaml);
next unless scalar @items;
my $dom = Urb::Dom->new;
$dom->Deserialise (@items);
say "=== FILE $path_yaml";
printf "INITIAL_COST %.17g\n", $assessor->Cost ('plot') * $dom->Area;
my $score = $assessor->_apply ($dom);
say "FAIL $_" for ($dom->Failures);
printf "SCORE %.17g\n", $score;
$dom->DESTROY;
}
0;

View file

@ -1,56 +0,0 @@
#!/usr/bin/env python3
"""Genome round-trip fitness parity (homemaker-py-k2g acceptance).
decode(encode(load(f))) canonicalises dead fields (inherited-cut divisions,
below-linked rotations, internal types) that the corpus carries in drifted
form. This scores every original and its round-tripped twin through the
oracle and demands identical fitness (1e-12 rel, Urb's ~1-ULP jitter) and
identical failure sets.
Run under the go-forward fitness: URB_NO_OCCLUSION=1 python3 experiments/genome_parity.py
"""
from __future__ import annotations
import math
import shutil
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, genome, oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
CORPUS = URB / "examples" / "programme-house"
def main() -> int:
with tempfile.TemporaryDirectory(prefix="genome_parity_") as tmp:
scratch = Path(tmp)
shutil.copy(CORPUS / "patterns.config", scratch)
originals, twins = [], []
for src in sorted(CORPUS.glob("*.dom")):
o = Path(shutil.copy(src, scratch))
t = scratch / ("rt_" + src.name)
dom.dump(genome.decode(genome.encode(dom.load(str(src)))), str(t))
originals.append(o)
twins.append(t)
s_orig = oracle.score_batch(originals, URB)
s_twin = oracle.score_batch(twins, URB)
bad = 0
for o, a, b in zip(originals, s_orig, s_twin):
if not math.isclose(a.fitness, b.fitness, rel_tol=1e-12) or a.fail_lines != b.fail_lines:
bad += 1
print(f"MISMATCH {o.name}: {a.fitness:.17g} ({a.n_fails} fails) vs "
f"{b.fitness:.17g} ({b.n_fails} fails)")
n = len(originals)
print(f"{'FAIL' if bad else 'OK'}: {n - bad}/{n} files fitness-identical "
f"after genome round-trip")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,183 +0,0 @@
#!/usr/bin/env python3
"""homemaker-py-gnw acceptance: per-leaf quality factors and per-storey
cost/value parity between homemaker.fitness and Urb's programme-driven fitness.
Runs experiments/dump_leaf_quality.pl (URB_NO_OCCLUSION oracle with per-leaf
DEBUG re-enabled) over the corpus, parses its output, and diffs against the
native evaluation: 7 quality factors + rate + area per leaf, cost/value per
storey, initial (plot) cost, and the leaf-scope failure set.
Usage: python3 experiments/leaf_parity.py [corpus_dir]
"""
from __future__ import annotations
import math
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, fitness, graph # noqa: E402
URB = Path("/home/bruno/src/urb")
CORPUS = Path(sys.argv[1]) if len(sys.argv) > 1 else URB / "examples" / "programme-house"
WRAPPER = Path(__file__).resolve().parent / "dump_leaf_quality.pl"
REL_TOL = 1e-9
# Failures emitted by the gnw scope (leaf quality + cost functions + the two
# per-leaf structural checks in process_storey). All carry a "level/" prefix —
# requiring it excludes building-level fails like 'no outside public access'
# (homemaker-py-hgg scope), which would otherwise match the ' access' suffix.
_LEAF_FAIL_RE = re.compile(
r"^\d+/.* (perpendicular|proportion|size|width|crinkliness|daylight|access"
r"|edge too long|unsupported covered outside|covered outside above ground)$"
)
_FACTOR_RE = re.compile(r"^ quality (\w+): (\S+)$")
_LEAF_RE = re.compile(
r"^leaf level: (\d+) id: ([lr]*) type: (\S+) rate: (\S+) area: (\S+) "
r"width: (\S+) proportion: (\S+)$"
)
_STOREY_RE = re.compile(r"^STOREY (\d+) cost: (\S+) value: (\S+)$")
def run_oracle(files: list[str]) -> dict[str, dict]:
"""Run the wrapper over all files; return per-file parsed records."""
proc = subprocess.run(
["perl", f"-I{URB}/lib", str(WRAPPER)] + files,
cwd=CORPUS,
env={"URB_NO_OCCLUSION": "1", "PATH": "/usr/bin:/bin"},
capture_output=True,
text=True,
)
if proc.returncode != 0:
sys.exit(f"wrapper failed:\n{proc.stderr}")
out: dict[str, dict] = {}
rec = None
pending: dict[str, float] = {}
for line in proc.stdout.splitlines():
if line.startswith("=== FILE "):
rec = out[line[len("=== FILE "):]] = {
"leaves": {}, "storeys": {}, "fails": [], "initial_cost": None,
"score": None,
}
pending = {}
continue
if rec is None:
continue
m = _FACTOR_RE.match(line)
if m:
pending[m.group(1)] = float(m.group(2))
continue
m = _LEAF_RE.match(line)
if m:
level, lid = int(m.group(1)), m.group(2)
rec["leaves"][(level, lid)] = {
"type": m.group(3),
"rate": float(m.group(4)),
"area": float(m.group(5)),
"factors": pending,
}
pending = {}
continue
m = _STOREY_RE.match(line)
if m:
rec["storeys"][int(m.group(1))] = (float(m.group(2)), float(m.group(3)))
continue
if line.startswith("INITIAL_COST "):
rec["initial_cost"] = float(line.split()[1])
elif line.startswith("FAIL "):
rec["fails"].append(line[len("FAIL "):])
elif line.startswith("SCORE "):
rec["score"] = float(line.split()[1])
return out
def native_eval(path: Path, fit: fitness.Fitness) -> dict:
root = dom.load(str(path))
fails: list[str] = []
fit.preprocess_building(root)
dom.merge_divided(root)
graphs = graph.build_graphs(root)
rec: dict = {"leaves": {}, "storeys": {}, "fails": fails,
"initial_cost": fit.plot_cost(root)}
for li, lvl in enumerate(dom.levels(root)):
se = fit.process_storey(lvl, graphs[li], li, fails.append)
rec["storeys"][li] = (se.cost, se.value)
for le in se.leaves:
rec["leaves"][(le.level, le.id)] = {
"type": le.type, "rate": le.rate, "area": le.area,
"factors": le.factors,
}
return rec
def close(a: float, b: float) -> bool:
return math.isclose(a, b, rel_tol=REL_TOL, abs_tol=1e-12)
def main() -> int:
files = sorted(p.name for p in CORPUS.glob("*.dom"))
conf, cost = fitness.load_config(CORPUS)
fit = fitness.Fitness(conf, cost)
oracle = run_oracle(files)
n_leaves = n_factors = 0
bad: dict[str, list[str]] = defaultdict(list)
for name in files:
o = oracle[name]
n = native_eval(CORPUS / name, fit)
if not close(o["initial_cost"], n["initial_cost"]):
bad[name].append(
f"initial cost {o['initial_cost']} != {n['initial_cost']}")
if set(o["leaves"]) != set(n["leaves"]):
bad[name].append(
f"leaf sets differ: oracle-only {sorted(set(o['leaves']) - set(n['leaves']))}, "
f"native-only {sorted(set(n['leaves']) - set(o['leaves']))}")
for key in sorted(set(o["leaves"]) & set(n["leaves"])):
ol, nl = o["leaves"][key], n["leaves"][key]
n_leaves += 1
for fld in ("rate", "area"):
if not close(ol[fld], nl[fld]):
bad[name].append(f"leaf {key} {fld}: {ol[fld]} != {nl[fld]}")
if ol["type"] != nl["type"]:
bad[name].append(f"leaf {key} type: {ol['type']} != {nl['type']}")
for fac, ov in ol["factors"].items():
n_factors += 1
nv = nl["factors"].get(fac)
if nv is None or not close(ov, nv):
bad[name].append(f"leaf {key} {fac}: {ov} != {nv}")
for li, (oc, ov) in o["storeys"].items():
nc, nv = n["storeys"].get(li, (None, None))
if nc is None or not close(oc, nc):
bad[name].append(f"storey {li} cost: {oc} != {nc}")
if nv is None or not close(ov, nv):
bad[name].append(f"storey {li} value: {ov} != {nv}")
o_fails = sorted(f for f in o["fails"] if _LEAF_FAIL_RE.search(f))
n_fails = sorted(n["fails"])
if o_fails != n_fails:
bad[name].append(
f"leaf fails differ:\n oracle: {o_fails}\n native: {n_fails}")
for name in sorted(bad):
print(f"MISMATCH {name}")
for msg in bad[name]:
print(f" {msg}")
print(f"\n{len(files)} files, {n_leaves} leaves, {n_factors} factors compared; "
f"{len(bad)} files with mismatches")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,103 +0,0 @@
#!/usr/bin/env python3
"""Operator validity + locality measurement (homemaker-py-nyb acceptance).
For each operator: apply 5 seeded instances per corpus design, score every
child through the oracle (validity = scores without error), and report
locality as (a) mean relative fitness perturbation and (b) mean geometry
perturbation the fraction of leaf rooms whose (type, footprint) changed.
High-locality operators keep both small, so warm-started inner loops stay
cheap (DESIGN.md §5).
Run under the go-forward fitness:
URB_NO_OCCLUSION=1 python3 experiments/operator_locality.py
"""
from __future__ import annotations
import shutil
import sys
import tempfile
from collections import Counter
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, genome, geometry, operators, oracle, programme # noqa: E402
URB = Path("/home/bruno/src/urb")
CORPUS = URB / "examples" / "programme-house"
FILES = ["2f45907abd9accac2a124d311732f749.dom", "candidate-002.dom",
"c964435454c459f86c3ed9a5a7621132.dom"]
SEEDS = range(5)
def leaf_signature(root: dom.Node) -> Counter:
sig = Counter()
for li, lvl in enumerate(dom.levels(root)):
for leaf in lvl.leaves():
corners = tuple(tuple(round(c, 6) for c in geometry.coordinate(leaf, i))
for i in range(4))
sig[(li, leaf.type, corners)] += 1
return sig
def geometry_perturbation(parent_sig: Counter, child: dom.Node) -> float:
child_sig = leaf_signature(child)
common = sum((parent_sig & child_sig).values())
return 1.0 - common / max(parent_sig.total(), child_sig.total())
def main() -> int:
types = sorted(programme.load_programme(str(CORPUS / "patterns.config"))) + ["C", "O"]
roots = {f: genome.decode(genome.encode(dom.load(str(CORPUS / f)))) for f in FILES}
with tempfile.TemporaryDirectory(prefix="op_locality_") as tmp:
scratch = Path(tmp)
shutil.copy(CORPUS / "patterns.config", scratch)
parents = {}
paths = []
for f, root in roots.items():
p = scratch / f
dom.dump(root, str(p))
paths.append(p)
for f, s in zip(roots, oracle.score_batch(paths, URB)):
parents[f] = s
jobs: list[tuple[str, str, dom.Node]] = [] # (op, desc, child)
for f, root in roots.items():
for name, op in operators.MUTATIONS.items():
for seed in SEEDS:
child, desc = op(root, np.random.default_rng(seed), types)
jobs.append((name, f, child))
for seed in SEEDS:
ca, cb, _ = operators.crossover(roots[FILES[0]], roots[FILES[1]],
np.random.default_rng(seed))
jobs.append(("crossover", FILES[0], ca))
jobs.append(("crossover", FILES[1], cb))
paths = []
for i, (_, _, child) in enumerate(jobs):
p = scratch / f"child_{i:03d}.dom"
dom.dump(child, str(p))
paths.append(p)
scores = oracle.score_batch(paths, URB) # raises if any child is invalid
sigs = {f: leaf_signature(root) for f, root in roots.items()}
stats: dict[str, list[tuple[float, float]]] = {}
for (name, f, child), s in zip(jobs, scores):
df = abs(s.fitness - parents[f].fitness) / parents[f].fitness
dg = geometry_perturbation(sigs[f], child)
stats.setdefault(name, []).append((df, dg))
print(f"{'operator':14s} {'n':>3s} {'mean |dF|/F':>12s} {'mean geom-pert':>15s}")
for name in sorted(stats):
dfs, dgs = zip(*stats[name])
print(f"{name:14s} {len(dfs):3d} {np.mean(dfs):12.3f} {np.mean(dgs):15.3f}")
print(f"\nall {len(jobs)} children scored by the oracle without error")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,89 +0,0 @@
"""Full-fitness frozen-topology optimisation.
Drive the equal-offset division ratios with a derivative-free optimiser against
the REAL oracle fitness (the whole objective, not an area proxy) on a fixed
topology. This removes both confounds of the earlier sweeps (partial objective,
proxy target) and answers the only question that matters next:
Is there geometry headroom above the EA's designs, or are they already
geometry-optima (=> the bottleneck is topology + the 0.5^n cliff)?
For each candidate: report fitness/fails before and after.
"""
import sys
from pathlib import Path
import numpy as np
from scipy.optimize import minimize
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, oracle, solver # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples/programme-house"
SCRATCH = Path(__file__).resolve().parents[1] / "scratch"
_EPS = 0.02
CANDIDATES = [
"candidate-002.dom", # ~0.0074 (MCP-refined)
"c964435454c459f86c3ed9a5a7621132.dom", # ~0.0037 (MCP baseline)
]
def optimise(src: Path, maxfev: int = 200):
import shutil
shutil.copy(src, SCRATCH / "orig.dom")
s0 = oracle.score(SCRATCH / "orig.dom", URB)
root = dom.load(str(src))
free = solver.free_branches(root)
x0 = np.array([b.division[0] for b in free], dtype=float)
opt_path = SCRATCH / "opt.dom"
best = {"f": s0.fitness, "fails": s0.n_fails, "x": x0.copy()}
def neg_fitness(x: np.ndarray) -> float:
xc = np.clip(x, _EPS, 1 - _EPS)
for j, b in enumerate(free):
b.division = [float(xc[j]), float(xc[j])]
dom.dump(root, str(opt_path))
try:
s = oracle.score(opt_path, URB)
except Exception: # noqa: BLE001
return 1e9
if s.fitness > best["f"]:
best.update(f=s.fitness, fails=s.n_fails, x=xc.copy())
return -s.fitness
minimize(
neg_fitness, x0, method="Nelder-Mead",
options={"maxfev": maxfev, "xatol": 1e-3, "fatol": 1e-12},
)
return s0, best, len(free)
def main() -> None:
SCRATCH.mkdir(exist_ok=True)
import shutil
shutil.copy(EX / "patterns.config", SCRATCH / "patterns.config")
maxfev = int(sys.argv[1]) if len(sys.argv) > 1 else 200
for name in CANDIDATES:
s0, best, ndof = optimise(EX / name, maxfev)
ratio = best["f"] / s0.fitness if s0.fitness else float("inf")
verdict = "IMPROVED" if best["f"] > s0.fitness * 1.001 else "no gain"
print(
f"{name:42s} dof={ndof:2d} "
f"orig={s0.fitness:.5g}(fails {s0.n_fails}) "
f"opt={best['f']:.5g}(fails {best['fails']}) "
f"x{ratio:.2f} {verdict}",
flush=True,
)
if __name__ == "__main__":
main()

View file

@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""Re-baseline the corpus under URB_NO_OCCLUSION=1 (homemaker-py-gp2).
Occlusion/daylight is disabled in Urb behind the URB_NO_OCCLUSION env flag
(daylight -> 1 everywhere, CIEsky illumination factor pinned to 1, i.e.
simple crinkliness). Flipping the flag changes every score, so this script
records the new baseline in one pass:
- per-file flag-on vs flag-off score and failure-set deltas (35 corpus files)
- batched oracle throughput under the flag
Run the inner-loop reference gains separately:
URB_NO_OCCLUSION=1 python3 experiments/accept_innerloop.py 400 cma
"""
from __future__ import annotations
import math
import os
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
CORPUS = URB / "examples" / "programme-house"
def score_corpus(paths: list[Path], flag: bool) -> tuple[list[oracle.Score], float]:
os.environ.pop("URB_NO_OCCLUSION", None)
if flag:
os.environ["URB_NO_OCCLUSION"] = "1"
t0 = time.perf_counter()
scores = oracle.score_batch(paths, URB)
return scores, time.perf_counter() - t0
def main() -> int:
with tempfile.TemporaryDirectory(prefix="rebaseline_") as tmp:
scratch = Path(tmp)
shutil.copy(CORPUS / "patterns.config", scratch)
paths = [Path(shutil.copy(d, scratch)) for d in sorted(CORPUS.glob("*.dom"))]
off, t_off = score_corpus(paths, flag=False)
on, t_on = score_corpus(paths, flag=True)
n_changed_score = n_changed_fails = 0
print(f"{'file':42s} {'flag-off':>12s} {'flag-on':>12s} {'ratio':>7s} fails off->on")
for p, a, b in zip(paths, off, on):
score_changed = not math.isclose(a.fitness, b.fitness, rel_tol=1e-9)
fails_changed = a.fail_lines != b.fail_lines
n_changed_score += score_changed
n_changed_fails += fails_changed
ratio = b.fitness / a.fitness if a.fitness else float("inf")
mark = "*" if fails_changed else " "
print(f"{p.name:42s} {a.fitness:12.6g} {b.fitness:12.6g} {ratio:7.3f} "
f"{a.n_fails}->{b.n_fails}{mark}")
n = len(paths)
print(f"\nscore changed: {n_changed_score}/{n} failure set changed: {n_changed_fails}/{n}")
print(f"batched s/dom: flag-off {t_off / n:.3f}, flag-on {t_on / n:.3f} "
f"(x{t_off / t_on:.2f})")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,79 +0,0 @@
"""Population sweep: warm-start refine every evolved candidate and tally results.
For each real .dom in the example dir, score it, run the solver as a geometry
optimiser (warm-start, no strip), and re-score. Reports how often bottom-up
sizing improves vs regresses total fitness, plus aggregate fail-count change.
This is a breadth check on the solver-as-optimiser role; raw fitness is still
confounded by the 0.5^n failure cliff and any topological defects, so the
fail-count and per-candidate detail matter as much as the win/loss tally.
"""
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, oracle, programme, solver # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples/programme-house"
def _is_candidate(p: Path) -> bool:
# real designs: 32-hex hashes or candidate-NNN; skip init and our scratch
name = p.stem
return name not in {"init", "original", "roundtrip", "solved", "refined"}
def main() -> None:
scratch = Path(__file__).resolve().parents[1] / "scratch"
scratch.mkdir(exist_ok=True)
shutil.copy(EX / "patterns.config", scratch / "patterns.config")
targets = programme.load_programme(str(EX / "patterns.config"))
doms = sorted(p for p in EX.glob("*.dom") if _is_candidate(p))
win = loss = tie = 0
fails_before = fails_after = 0
rows = []
for src in doms:
try:
shutil.copy(src, scratch / "orig.dom")
s0 = oracle.score(scratch / "orig.dom", URB)
root = dom.load(str(src))
# gentlest refiner: nudge cut POSITIONS for programme-room area only,
# keep evolved cut angles and leave circulation/shape untouched.
solver.solve_ratios(
root, targets, strip=False, perpendicular=False,
weight_width=0.0, weight_proportion=0.0, min_width_generic=0.0,
)
dom.dump(root, str(scratch / "ref.dom"))
s1 = oracle.score(scratch / "ref.dom", URB)
except Exception as e: # noqa: BLE001
rows.append(f" {src.name:40s} ERROR {e}")
continue
fails_before += s0.n_fails
fails_after += s1.n_fails
if s1.fitness > s0.fitness * 1.001:
win += 1
mark = ""
elif s1.fitness < s0.fitness * 0.999:
loss += 1
mark = ""
else:
tie += 1
mark = "="
rows.append(
f" {mark} {src.name:40s} {s0.fitness:.4g} -> {s1.fitness:.4g}"
f" fails {s0.n_fails}->{s1.n_fails}"
)
print("\n".join(rows))
n = win + loss + tie
print(f"\n{n} candidates: {win} improved, {loss} regressed, {tie} tied")
print(f"total fails: {fails_before} -> {fails_after}")
if __name__ == "__main__":
main()

View file

@ -1,90 +0,0 @@
"""Go/no-go experiment for bottom-up sizing.
Take a real evolved .dom, throw away its division ratios, and re-solve them from
the programme's target sizes alone. Score three versions through the Perl oracle:
original -- the evolved .dom as-is (baseline)
roundtrip -- loaded and re-emitted unmodified (checks dump fidelity)
solved -- ratios stripped to 0.5 then solved from programme targets
If `solved` scores >= `original`, sizing can be recovered from the programme
without the evolved geometry, and the EA only needs to search topology.
Usage:
python experiments/resolve_ratios.py [source.dom] [--urb /path/to/urb]
"""
import argparse
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, oracle, programme, solver # noqa: E402
DEFAULT_URB = Path("/home/bruno/src/urb")
DEFAULT_SRC = DEFAULT_URB / "examples/programme-house/candidate-002.dom"
def _score_in(scratch: Path, name: str, root: dom.Node, urb: Path) -> oracle.Score:
path = scratch / name
dom.dump(root, str(path))
return oracle.score(path, urb)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("source", nargs="?", default=str(DEFAULT_SRC))
ap.add_argument("--urb", default=str(DEFAULT_URB))
args = ap.parse_args()
src = Path(args.source).resolve()
urb = Path(args.urb).resolve()
config = src.parent / "patterns.config"
scratch = Path(__file__).resolve().parents[1] / "scratch"
scratch.mkdir(exist_ok=True)
shutil.copy(config, scratch / "patterns.config")
targets = programme.load_programme(str(config))
# baseline: score the original file as-is
orig_copy = scratch / "original.dom"
shutil.copy(src, orig_copy)
s_orig = oracle.score(orig_copy, urb)
# roundtrip: load + re-emit unmodified
s_round = _score_in(scratch, "roundtrip.dom", dom.load(str(src)), urb)
# solved: strip ratios and re-solve from programme targets
root = dom.load(str(src))
print("--- programme leaves BEFORE solve (ratios intact) ---")
print(solver.area_report(root, targets))
res = solver.solve_ratios(root, targets, strip=True)
print("\n--- programme leaves AFTER solve (from targets, ratios stripped) ---")
print(solver.area_report(root, targets))
print(f"\nsolver: cost={res.cost:.4f} nfev={res.nfev} success={res.success}")
s_solved = _score_in(scratch, "solved.dom", root, urb)
# refined: warm-start from the evolved ratios (solver as geometry optimiser)
root2 = dom.load(str(src))
solver.solve_ratios(root2, targets, strip=False)
s_refined = _score_in(scratch, "refined.dom", root2, urb)
print("\n=== FITNESS (via urb-fitness.pl oracle) ===")
for label, s in (
("original", s_orig),
("roundtrip", s_round),
("solved", s_solved),
("refined", s_refined),
):
print(f" {label:9s} fitness={s.fitness:.10g} fails={s.n_fails}")
print("\nVERDICT:")
print(f" solved (strip, frozen topology): {'>=' if s_solved.fitness >= s_orig.fitness else '<'} original")
print(f" refined (warm-start optimiser): {'>=' if s_refined.fitness >= s_orig.fitness else '<'} original")
if __name__ == "__main__":
main()

View file

@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""End-to-end memetic search on programme-house (homemaker-py-b39 acceptance).
Runs driver.search from a corpus seed within a stated oracle-evaluation
budget, logs every improvement, writes the best design to
scratch/search_best.dom and independently re-scores it through the oracle to
prove the output is a valid .dom whose recorded fitness is reproducible.
The interesting bar: the seed's geometry-only optimum (inner loop alone,
DESIGN §4.7 reference: c964435 -> 0.00674). Anything above that is value
added by *topology* search.
Run under the go-forward fitness:
URB_NO_OCCLUSION=1 python3 experiments/run_search.py [budget] [rng-seed] [seed.dom] [out.dom]
"""
from __future__ import annotations
import math
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, driver, oracle # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples" / "programme-house"
SEED_FILE = EX / "c964435454c459f86c3ed9a5a7621132.dom"
OUT = Path(__file__).resolve().parents[1] / "scratch" / "search_best.dom"
def main() -> int:
budget = int(sys.argv[1]) if len(sys.argv) > 1 else 2000
rng_seed = int(sys.argv[2]) if len(sys.argv) > 2 else 0
seed_file = Path(sys.argv[3]) if len(sys.argv) > 3 else SEED_FILE
out = Path(sys.argv[4]) if len(sys.argv) > 4 else OUT
print(f"seed={seed_file.name} budget={budget} oracle evals, rng seed {rng_seed}",
flush=True)
r = driver.search(dom.load(str(seed_file)), EX, budget=budget,
urb_root=URB, seed=rng_seed, log=lambda m: print(m, flush=True))
print(f"\ndone: {r.n_evals} oracle evals across {r.n_topologies} topologies")
print(f"best: {r.best.fitness:.6g} (fails {r.best.n_fails}) via {r.best.lineage}")
print("population: " + ", ".join(f"{p.fitness:.4g}/{p.n_fails}f" for p in r.population))
out.parent.mkdir(parents=True, exist_ok=True)
config_src = EX / "patterns.config"
if config_src.exists() and not (out.parent / "patterns.config").exists():
shutil.copy(config_src, out.parent)
dom.dump(r.best.root, str(out))
s = oracle.score(out, URB)
ok = math.isclose(s.fitness, r.best.fitness, rel_tol=1e-9)
print(f"\n{out} re-scored standalone: {s.fitness:.6g} ({s.n_fails} fails) "
f"-> {'MATCHES search record' if ok else 'MISMATCH'}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -109,7 +109,6 @@ def main() -> int:
restart_patience=restart_patience, restart_patience=restart_patience,
seed_adjacency_aware=adj, seed_adjacency_aware=adj,
seed_proportion_aware=prop, seed_proportion_aware=prop,
# urb_root not needed: use_native=True is the default
) )
elapsed = time.perf_counter() - t0 elapsed = time.perf_counter() - t0

View file

@ -1,79 +0,0 @@
"""Equal-offset (perpendicular) warm-start sweep with failure-type histogram.
Isolates which fitness constraints break when we move equal-offset cuts to fix
programme room sizes. Equal offset (a == b) keeps walls perpendicular on
near-rectangular plots, so any regression here is NOT a perpendicularity
artifact -- it tells us what sizing genuinely trades against.
"""
import collections
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker_layout import dom, oracle, programme, solver # noqa: E402
URB = Path("/home/bruno/src/urb")
EX = URB / "examples/programme-house"
def classify(line: str) -> str:
s = line.strip().lower()
if not s or s == "---" or s.startswith(("- ", "type:", "issue:", "actual:", "min:")):
# structured YAML fails: pull the keyword if present
if "staircase" in s:
return "staircase"
if "access" in s or "inaccessible" in s:
return "access"
if not s or s == "---":
return ""
for key in ("perpendicular", "crinkliness", "width", "proportion", "size",
"adjacent", "access", "inaccessible", "level", "staircase"):
if key in s:
return "adjacency" if key == "adjacent" else (
"access" if key == "inaccessible" else key)
return "other"
def tally(fails: str, counter: collections.Counter) -> None:
for line in fails.splitlines():
t = classify(line)
if t:
counter[t] += 1
def main() -> None:
scratch = Path(__file__).resolve().parents[1] / "scratch"
scratch.mkdir(exist_ok=True)
shutil.copy(EX / "patterns.config", scratch / "patterns.config")
targets = programme.load_programme(str(EX / "patterns.config"))
before = collections.Counter()
after = collections.Counter()
doms = sorted(p for p in EX.glob("*.dom") if p.stem != "init")
for src in doms:
shutil.copy(src, scratch / "orig.dom")
s0 = oracle.score(scratch / "orig.dom", URB)
root = dom.load(str(src))
solver.solve_ratios( # equal offset, shape-aware (strong width/proportion)
root, targets, strip=False, perpendicular=True,
weight_width=3.0, weight_proportion=2.0, min_width_generic=1.5,
)
dom.dump(root, str(scratch / "ref.dom"))
s1 = oracle.score(scratch / "ref.dom", URB)
tally(s0.fails, before)
tally(s1.fails, after)
types = sorted(set(before) | set(after), key=lambda t: -(after[t] - before[t]))
print(f"{'failure type':16s} {'before':>8s} {'after':>8s} {'delta':>8s}")
for t in types:
print(f"{t:16s} {before[t]:8d} {after[t]:8d} {after[t] - before[t]:+8d}")
print(f"{'TOTAL':16s} {sum(before.values()):8d} {sum(after.values()):8d} "
f"{sum(after.values()) - sum(before.values()):+8d}")
if __name__ == "__main__":
main()

View file

@ -8,7 +8,8 @@ mandatory, homemaker-py-8cs: cold starts never catch up at equal budget).
Budgets are stated and accounted in **oracle evaluations** (scored .dom Budgets are stated and accounted in **oracle evaluations** (scored .dom
files), never generations (§4.6 arithmetic). This driver is deliberately files), never generations (§4.6 arithmetic). This driver is deliberately
small-scale for the Phase-2 proof on the batched Perl oracle; scaling up small-scale for the Phase-2 proof on the batched Perl oracle (since removed,
DESIGN.md §39.21); scaling up
waits for the native fitness (Phase 3). waits for the native fitness (Phase 3).
Cold-start bootstrap (homemaker-py-0px): when the seed is an undivided bare Cold-start bootstrap (homemaker-py-0px): when the seed is an undivided bare
@ -164,7 +165,7 @@ def random_topology(seed_root: dom.Node, n_leaves: int,
return root return root
def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw, def _evaluate(root: dom.Node, programme_dir, x0, budget, inner_kw,
lineage: str, want_grade: bool = False, lineage: str, want_grade: bool = False,
feasibility_max_shape_fails: int | None = None, feasibility_max_shape_fails: int | None = None,
best_n_fails: int | None = None, best_n_fails: int | None = None,
@ -239,7 +240,7 @@ def _evaluate(root: dom.Node, programme_dir, urb_root, x0, budget, inner_kw,
sig=genome.signature(root), n_hard=0, n_soft=pred) sig=genome.signature(root), n_hard=0, n_soft=pred)
return ind, 1 return ind, 1
r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget, r = innerloop.optimise(root, programme_dir, x0=x0, budget=budget,
urb_root=urb_root, conf_overrides=overrides, **inner_kw) conf_overrides=overrides, **inner_kw)
# §11.4: read the graded proximity scalar off the optimised tree. The inner # §11.4: read the graded proximity scalar off the optimised tree. The inner
# loop left ``root`` at the optimum (Lamarckian write-back), so re-scoring a # loop left ``root`` at the optimum (Lamarckian write-back), so re-scoring a
# copy reproduces r.fitness/r.n_fails exactly and adds the grade. One extra # copy reproduces r.fitness/r.n_fails exactly and adds the grade. One extra
@ -276,7 +277,6 @@ def search(
seed: int = 0, seed: int = 0,
types: list[str] | None = None, types: list[str] | None = None,
inner_kw: dict | None = None, inner_kw: dict | None = None,
urb_root=None,
log=None, log=None,
n_workers: int = 1, n_workers: int = 1,
use_lex: bool = True, use_lex: bool = True,
@ -429,9 +429,6 @@ def search(
exactly instead of un-dividing and regrowing it. Gated the same way as exactly instead of un-dividing and regrowing it. Gated the same way as
``ruin_recreate`` (zero mutation weight unless enabled). ``ruin_recreate`` (zero mutation weight unless enabled).
""" """
from .oracle import DEFAULT_URB_ROOT
urb_root = urb_root or DEFAULT_URB_ROOT
rng = np.random.default_rng(seed) rng = np.random.default_rng(seed)
inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {})) inner_kw = dict(_CHILD_INNER_KW, **(inner_kw or {}))
# §12.3 M3 reassociate (homemaker-py-9gp.2) is default-OFF: force its weight to # §12.3 M3 reassociate (homemaker-py-9gp.2) is default-OFF: force its weight to
@ -607,7 +604,7 @@ def search(
mx = feasibility_max_shape_fails if (filter_on and feasibility_filter) else None mx = feasibility_max_shape_fails if (filter_on and feasibility_filter) else None
best_nf = result.best.n_fails if result.best is not None else None best_nf = result.best.n_fails if result.best is not None else None
full = [ full = [
(root, programme_dir, urb_root, x0, budget_, kw_, lin, use_grade, (root, programme_dir, x0, budget_, kw_, lin, use_grade,
mx, best_nf, leaf_sharing, superpose, max_share, conn_grade, mx, best_nf, leaf_sharing, superpose, max_share, conn_grade,
collapse_insearch, multi_use, shapecurve_warmstart, shapecurve_prune) collapse_insearch, multi_use, shapecurve_warmstart, shapecurve_prune)
for root, x0, budget_, kw_, lin in tasks for root, x0, budget_, kw_, lin in tasks
@ -687,7 +684,7 @@ def search(
# random divide+retype walk that leaves required rooms absent. # random divide+retype walk that leaves required rooms absent.
_run_batch([_make_seed_task(str(i)) for i in range(pop_size)]) _run_batch([_make_seed_task(str(i)) for i in range(pop_size)])
else: else:
seed_ind, used = _evaluate(copy.deepcopy(seed_root), programme_dir, urb_root, seed_ind, used = _evaluate(copy.deepcopy(seed_root), programme_dir,
x0=None, budget=seed_budget, x0=None, budget=seed_budget,
inner_kw={}, lineage="seed", inner_kw={}, lineage="seed",
want_grade=use_grade, want_grade=use_grade,
@ -843,7 +840,7 @@ def polish_finish(
# No polish: re-optimise the unfolded genome's ratios once and score it # No polish: re-optimise the unfolded genome's ratios once and score it
# canonically so the written .dom and reported fitness are honest. # canonically so the written .dom and reported fitness are honest.
ind, used = _evaluate( ind, used = _evaluate(
unfolded, programme_dir, None, x0=None, budget=rescore_budget, unfolded, programme_dir, x0=None, budget=rescore_budget,
inner_kw={}, lineage="unfold", leaf_sharing=False, superpose=superpose, inner_kw={}, lineage="unfold", leaf_sharing=False, superpose=superpose,
multi_use=multi_use, collapse_insearch=collapse_insearch) multi_use=multi_use, collapse_insearch=collapse_insearch)
r2 = SearchResult(best=ind, population=[ind], n_evals=used, n_topologies=1) r2 = SearchResult(best=ind, population=[ind], n_evals=used, n_topologies=1)

View file

@ -12,7 +12,8 @@ Source of truth: ``Urb::Dom::Fitness::{Base,Leaf,Storey,ProgrammeDriven}``.
DESCOPE (DESIGN.md §6, decision 2026-06-12): this ports *simple* crinkliness DESCOPE (DESIGN.md §6, decision 2026-06-12): this ports *simple* crinkliness
the CIEsky illumination factor is pinned to 1, exactly what Urb computes under the CIEsky illumination factor is pinned to 1, exactly what Urb computes under
``URB_NO_OCCLUSION=1``. ``quality_daylight`` is likewise pinned to 1. Parity ``URB_NO_OCCLUSION=1``. ``quality_daylight`` is likewise pinned to 1. Parity
targets the *flagged* oracle, never stock Urb. targeted the *flagged* oracle, never stock Urb. The oracle itself is gone
(DESIGN.md §39.21); this is now the only evaluator.
Call ``dom.merge_divided(root)`` and rebuild graphs before ``process_storey`` Call ``dom.merge_divided(root)`` and rebuild graphs before ``process_storey``
storey processing runs on the MERGED tree (two-phase pattern, see graph.py). storey processing runs on the MERGED tree (two-phase pattern, see graph.py).

View file

@ -7,8 +7,8 @@ ownership) against the FULL fitness. Never a proxy objective — §4.2 falsified
that; the full objective's ``0.5^n`` failure cliff is what protects the inner that; the full objective's ``0.5^n`` failure cliff is what protects the inner
loop from trading into new failures (§4.5). loop from trading into new failures (§4.5).
Fitness defaults to the native Python evaluator (Phase 3). The Perl oracle Fitness is the native Python evaluator. The Perl oracle it was ported from is
(``OracleEvaluator``) is kept for validation but is no longer used in search. gone (DESIGN.md §39.21) -- "oracle call" below means one batched evaluation.
Warm-starting from a parent's optimised ratios is ``x0=`` (§5 decision 6, Warm-starting from a parent's optimised ratios is ``x0=`` (§5 decision 6,
Lamarckian inheritance). Lamarckian inheritance).
""" """
@ -22,7 +22,7 @@ from pathlib import Path
import numpy as np import numpy as np
from . import dom, oracle, solver from . import dom, solver
_EPS = 0.02 # keep cuts off the edges; matches solver/_experiments convention _EPS = 0.02 # keep cuts off the edges; matches solver/_experiments convention
@ -60,69 +60,9 @@ class Result:
n_oracle_calls: int # perl invocations n_oracle_calls: int # perl invocations
class OracleEvaluator:
"""Scores ratio vectors for a frozen topology via the batched oracle.
Owns a scratch directory seeded with the programme config (and occlusion
field, if any) so ``urb-fitness.pl`` finds them in its working directory.
Use as a context manager, or call ``close()``.
"""
_CONFIGS = ("patterns.config", "costs.config", "occlusion.field")
def __init__(
self,
root: dom.Node,
programme_dir: str | Path,
urb_root: str | Path = oracle.DEFAULT_URB_ROOT,
):
self.root = root
self.free = solver.free_branches(root)
self.urb_root = Path(urb_root)
self._dir = Path(tempfile.mkdtemp(prefix="innerloop_"))
for name in self._CONFIGS:
src = Path(programme_dir) / name
if src.exists():
shutil.copy(src, self._dir)
self.n_evals = 0
self.n_oracle_calls = 0
def __enter__(self) -> "OracleEvaluator":
return self
def __exit__(self, *exc) -> None:
self.close()
def close(self) -> None:
shutil.rmtree(self._dir, ignore_errors=True)
@property
def x_current(self) -> np.ndarray:
# Midpoint projection: legacy designs carry slightly unequal offsets
# (a != b); (a+b)/2 is the least-damaging equal-offset start.
return np.array([(b.division[0] + b.division[1]) / 2 for b in self.free], dtype=float)
def apply(self, x: np.ndarray) -> None:
xc = np.clip(x, _EPS, 1 - _EPS)
for j, b in enumerate(self.free):
b.division = [float(xc[j]), float(xc[j])]
def evaluate(self, xs: list[np.ndarray]) -> list[oracle.Score]:
"""Score a population of ratio vectors in one oracle invocation."""
paths = []
for i, x in enumerate(xs):
self.apply(x)
p = self._dir / f"member_{i:04d}.dom"
dom.dump(self.root, str(p))
paths.append(p)
scores = oracle.score_batch(paths, self.urb_root)
self.n_evals += len(xs)
self.n_oracle_calls += 1
return scores
def compass_search( def compass_search(
ev: OracleEvaluator, ev: NativeEvaluator,
x0: np.ndarray, x0: np.ndarray,
budget: int = 200, budget: int = 200,
step0: float = 0.25, step0: float = 0.25,
@ -196,7 +136,7 @@ def compass_search(
def cma_search( def cma_search(
ev: OracleEvaluator, ev: NativeEvaluator,
x0: np.ndarray, x0: np.ndarray,
budget: int = 200, budget: int = 200,
sigmas: tuple[float, ...] = (0.05, 0.15), sigmas: tuple[float, ...] = (0.05, 0.15),
@ -208,7 +148,7 @@ def cma_search(
Covariance adaptation handles the diagonal ridges of the ``0.5^n`` Covariance adaptation handles the diagonal ridges of the ``0.5^n``
landscape that stall axis-aligned pattern search; the ask/tell population landscape that stall axis-aligned pattern search; the ask/tell population
maps one-to-one onto ``OracleEvaluator.evaluate``. maps one-to-one onto ``NativeEvaluator.evaluate``.
One sigma does not fit all warm starts (measured at budget 200): One sigma does not fit all warm starts (measured at budget 200):
2f45907 needs a *local* phase at sigma 0.15 the search wanders out of 2f45907 needs a *local* phase at sigma 0.15 the search wanders out of
@ -267,7 +207,7 @@ class _BudgetExhausted(Exception):
def nm_search( def nm_search(
ev: "OracleEvaluator | NativeEvaluator", ev: "NativeEvaluator",
x0: np.ndarray, x0: np.ndarray,
budget: int = 200, budget: int = 200,
seed: int = 0, seed: int = 0,
@ -327,7 +267,7 @@ from dataclasses import dataclass as _dc
@_dc @_dc
class _NativeScore: class _NativeScore:
"""oracle.Score-compatible result from native fitness.""" """Scalar + failure set for one ratio vector."""
fitness: float fitness: float
fail_lines: tuple fail_lines: tuple
@ -340,8 +280,7 @@ class _NativeScore:
class NativeEvaluator: class NativeEvaluator:
"""Scores ratio vectors for a frozen topology via the native Python fitness. """Scores ratio vectors for a frozen topology via the native Python fitness.
Drop-in replacement for ``OracleEvaluator``; no temp directory, no Perl Each ``evaluate`` call runs ``Fitness.score_with_fails``
startup overhead. Each ``evaluate`` call runs ``Fitness.score_with_fails``
serially over the batch (all in-process, no parallelism needed at this serially over the batch (all in-process, no parallelism needed at this
scale). scale).
""" """
@ -355,7 +294,7 @@ class NativeEvaluator:
conf, cost = fit_mod.load_config(programme_dir, overrides=conf_overrides) conf, cost = fit_mod.load_config(programme_dir, overrides=conf_overrides)
self._fit = fit_mod.Fitness(conf, cost) self._fit = fit_mod.Fitness(conf, cost)
self.n_evals = 0 self.n_evals = 0
self.n_oracle_calls = 0 # kept for interface parity with OracleEvaluator self.n_oracle_calls = 0 # legacy name: batches, not Perl calls
def __enter__(self) -> "NativeEvaluator": def __enter__(self) -> "NativeEvaluator":
return self return self
@ -376,7 +315,7 @@ class NativeEvaluator:
def evaluate(self, xs: list[np.ndarray]) -> "list[_NativeScore]": def evaluate(self, xs: list[np.ndarray]) -> "list[_NativeScore]":
"""Score a batch of ratio vectors; returns objects with .fitness / """Score a batch of ratio vectors; returns objects with .fitness /
.n_fails / .fail_lines matching the oracle.Score interface.""" .n_fails / .fail_lines."""
import copy import copy
results = [] results = []
@ -396,8 +335,7 @@ def optimise(
x0: np.ndarray | None = None, x0: np.ndarray | None = None,
budget: int = 200, budget: int = 200,
method: str = "nm", method: str = "nm",
use_native: bool = True,
urb_root: str | Path = oracle.DEFAULT_URB_ROOT,
conf_overrides: dict | None = None, conf_overrides: dict | None = None,
**search_kw, **search_kw,
) -> Result: ) -> Result:
@ -407,12 +345,10 @@ def optimise(
parent's optimised ratios for a Lamarckian warm start. On return ``root`` parent's optimised ratios for a Lamarckian warm start. On return ``root``
carries the best ratios found. carries the best ratios found.
``use_native=True`` (default) uses the native Python fitness; set False to evaluate with the native Python fitness.
fall back to the Perl oracle (kept for validation only).
""" """
ev_cls = NativeEvaluator if use_native else OracleEvaluator ev_cls = NativeEvaluator
ev_args = ((root, programme_dir, conf_overrides) if use_native ev_args = (root, programme_dir, conf_overrides)
else (root, programme_dir, urb_root))
with ev_cls(*ev_args) as ev: with ev_cls(*ev_args) as ev:
if x0 is None: if x0 is None:
x0 = ev.x_current x0 = ev.x_current

View file

@ -1,190 +0,0 @@
"""Phase-1 fitness oracle: score ``.dom`` files via Urb's ``urb-fitness.pl``.
This is the only throwaway component. It shells out to the Perl evaluator so we
can validate the Python search core against the trusted fitness before porting
fitness to Python (Phase 3). ``urb-fitness.pl`` reads ``patterns.config`` from
its working directory, so the ``.dom`` must live beside the programme config.
``urb-fitness.pl`` accepts many ``.dom`` paths per invocation; ``score_batch``
exploits this so the ~0.65 s Perl startup amortises across a generation
(DESIGN.md §4.6: ~0.99 s/dom batched vs ~1.65 s/dom single). Note the Perl
script computes the occlusion field from the *first* dom in a batch and reuses
it for the rest; ``experiments/bench_batch_oracle.py`` verifies this leaves
corpus scores identical to single-file calls.
Two flavours of Urb-side nondeterminism to know about (both from Perl's
per-process hash-order randomisation, neither a batching artifact): ``.fails``
line *order* varies between runs (use ``Score.fail_lines``), and the score
itself can flip by ~1 ULP. Compare fitness with a relative tolerance
(``math.isclose(..., rel_tol=1e-12)``), never ``==``.
"""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
import yaml
DEFAULT_URB_ROOT = Path("/home/bruno/src/urb")
def _structured_fail_to_str(f: dict) -> str:
"""Convert a structured failure dict (llm-agent-mcp branch format) to the
plain-text string that master urb/ProgrammeDriven.pm would have emitted."""
t = f.get("type", "")
if t == "level":
return f"{f['space']} on wrong level (level {f['actual']}, expected {f['required']})"
if t == "missing":
code = f["code"]
c = f.get("constraint")
if c == "size":
return f"missing {code}: would need size check"
if c == "width":
return f"missing {code}: would need width check"
if c == "proportion":
return f"missing {code}: would need proportion check"
if c == "adjacency":
return f"missing {code}: would need adjacency to {f.get('target', '')}"
if c == "level":
return f"missing {code}: would need to be on level {f['required']}"
if c == "vertical":
return f"missing {code}: would need connection to {f.get('target', '')} below"
if f.get("critical"):
return f"missing required space: {code} (critical)"
return f"missing required space: {code}"
if t == "count":
return f"too many spaces: {f['code']} (found {f['actual']}, expected {f['expected']})"
if t == "adjacency":
return f"{f['node']} ({f['space']}) not adjacent to {f['target']}"
if t == "vertical":
return f"{f['space']} not connected to {f['target']} below"
if t == "staircase":
issue = f.get("issue", "")
if issue == "volume":
return "staircase volume"
if issue == "count":
actual = f.get("actual", 0)
if "min" in f:
return f"too few stairs ({actual}, min {f['min']})"
if "max" in f:
return f"too many stairs ({actual}, max {f['max']})"
if t == "storey":
if f.get("issue") == "limit":
return "storey limit"
if f.get("issue") == "minimum":
return "storey minimum"
if t == "access" and f.get("issue") == "no_outside_public_access":
return "no outside public access"
return str(f)
def _parse_fails(text: str) -> list[str]:
"""Parse a .fails file that may contain a YAML block followed by plain-text
lines (urb branch format) or only plain-text lines (master format)."""
text = text.strip()
if not text:
return []
if not text.startswith("---"):
return [line.strip() for line in text.splitlines() if line.strip()]
# Split YAML block from trailing plain-text lines: YAML list items start
# with "- " or are indented; once we hit a non-indented, non-dash line
# that isn't blank or the "---" marker, the YAML part has ended.
yaml_lines: list[str] = []
plain_lines: list[str] = []
in_yaml = True
for line in text.splitlines():
if in_yaml:
stripped = line.strip()
if not stripped or stripped == "---" or stripped.startswith("-") or line[:1] == " ":
yaml_lines.append(line)
else:
in_yaml = False
plain_lines.append(stripped)
else:
if line.strip():
plain_lines.append(line.strip())
result: list[str] = []
try:
doc = yaml.safe_load("\n".join(yaml_lines))
if isinstance(doc, list):
for item in doc:
if isinstance(item, dict):
result.append(_structured_fail_to_str(item))
except yaml.YAMLError:
pass
result.extend(plain_lines)
return result
@dataclass
class Score:
fitness: float
fails: str # raw .fails content (YAML and/or plain lines)
@property
def fail_lines(self) -> tuple[str, ...]:
"""Failure messages as a sorted tuple — Perl's per-process hash-order
randomisation shuffles the raw ``.fails`` line order between runs, so
comparisons must be order-insensitive."""
return tuple(sorted(_parse_fails(self.fails)))
@property
def n_fails(self) -> int:
return len(self.fail_lines)
def score_batch(
dom_paths: Sequence[str | Path], urb_root: str | Path = DEFAULT_URB_ROOT
) -> list[Score]:
"""Score many ``.dom`` files in one ``urb-fitness.pl`` invocation.
All files must live in the same directory (the working directory, where
``patterns.config`` is found). Results are returned in input order.
"""
paths = [Path(p).resolve() for p in dom_paths]
if not paths:
return []
cwd = paths[0].parent
for p in paths:
if p.parent != cwd:
raise ValueError(f"batch spans directories: {p} not in {cwd}")
Path(f"{p}.score").unlink(missing_ok=True)
Path(f"{p}.fails").unlink(missing_ok=True)
urb_root = Path(urb_root).resolve()
env = {**os.environ, "DEBUG": "1", "URB_NO_OCCLUSION": "1"}
proc = subprocess.run(
["perl", f"-I{urb_root}/lib", str(urb_root / "bin" / "urb-fitness.pl")]
+ [p.name for p in paths],
cwd=cwd,
env=env,
capture_output=True,
text=True,
)
results = []
for p in paths:
score_file = Path(f"{p}.score")
if not score_file.exists():
raise RuntimeError(
f"urb-fitness.pl produced no score for {p}\n"
f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
)
fails_file = Path(f"{p}.fails")
results.append(
Score(
fitness=float(score_file.read_text().strip()),
fails=fails_file.read_text() if fails_file.exists() else "",
)
)
return results
def score(dom_path: str | Path, urb_root: str | Path = DEFAULT_URB_ROOT) -> Score:
return score_batch([dom_path], urb_root)[0]

View file

@ -1,12 +1,10 @@
"""Corpus-backed tests for dom round-trip, free-branch ownership, and fitness parity. """Corpus-backed tests for dom round-trip, free-branch ownership, and fitness parity.
Skipped when the Urb checkout is absent (these need only its .dom files, not Skipped when the Urb checkout is absent (these need only its .dom files, not
perl). The parity tests compare native Python fitness against cached oracle perl). Parity against the Perl oracle used to live here; the oracle is gone
scores and failure sets (generated with URB_NO_OCCLUSION=1). (DESIGN.md §39.21) and those tests never ran, so they went with it.
""" """
import math
import re
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -15,17 +13,6 @@ from homemaker_layout import dom, solver
CORPUS = Path(__file__).parent.parent / "examples" / "programme-house" CORPUS = Path(__file__).parent.parent / "examples" / "programme-house"
# The parity fixtures are the Perl corpus, whose files Urb names by MD5. Any
# other .dom in this directory is something a later session generated -- a
# search artefact, a candidate, a seed -- and must NEVER be used as a parity
# fixture: its .score, if one exists, was written by the NATIVE scorer, so
# comparing against it compares the native scorer with itself and passes
# whatever the native scorer says. That is exactly what was happening here
# until 39.20; see `homemaker-py-*` for restoring real oracle fixtures.
_ORACLE_NAME = re.compile(r"^[0-9a-f]{32}\.dom$")
ORACLE_FIXTURES = sorted(p for p in CORPUS.glob("*.dom")
if _ORACLE_NAME.match(p.name))
pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Corpus not available") pytestmark = pytest.mark.skipif(not CORPUS.is_dir(), reason="Corpus not available")
@ -133,64 +120,7 @@ def _native_evaluate(src: Path):
return score, frozenset(failures) return score, frozenset(failures)
def _oracle_result(src: Path):
"""Read cached oracle score and failure set (URB_NO_OCCLUSION=1)."""
from homemaker_layout.oracle import Score
score_file = Path(str(src) + ".score")
fails_file = Path(str(src) + ".fails")
if not score_file.exists():
pytest.skip(
f"No oracle score committed for {src.name}. `.gitignore` excludes "
"*.dom.score and *.dom.fails, and none has ever been tracked, so "
"native-vs-Perl parity is UNVERIFIED in this repository -- these "
"cases have always skipped on a clean checkout (DESIGN.md §39.20). "
"Regenerating them with the native scorer would not fix it; the "
"cache has to come from the Perl oracle.")
oracle_score = float(score_file.read_text().strip())
oracle_fails = Score(
fitness=oracle_score,
fails=fails_file.read_text() if fails_file.exists() else "",
).fail_lines
return oracle_score, frozenset(oracle_fails)
@pytest.mark.parametrize("src", ORACLE_FIXTURES, ids=lambda p: p.name)
def test_native_fitness_score_parity(src):
"""Native score matches oracle within 1e-4 relative tolerance."""
native_score, _ = _native_evaluate(src)
oracle_score, _ = _oracle_result(src)
assert math.isclose(native_score, oracle_score, rel_tol=1e-4, abs_tol=1e-15), (
f"{src.name}: native={native_score:.6e} oracle={oracle_score:.6e}"
)
@pytest.mark.parametrize("src", ORACLE_FIXTURES, ids=lambda p: p.name)
def test_native_fitness_fail_set_parity(src):
"""Native failure set matches oracle failure set exactly."""
_, native_fails = _native_evaluate(src)
_, oracle_fails = _oracle_result(src)
only_native = native_fails - oracle_fails
only_oracle = oracle_fails - native_fails
assert not only_native and not only_oracle, (
f"{src.name}: only_native={sorted(only_native)} only_oracle={sorted(only_oracle)}"
)
def test_parity_fixtures_are_never_session_artefacts():
"""Guard for the defect §39.20 records.
The parity tests read a cached `.score` beside each `.dom` and treat it as
the Perl oracle's answer. Nothing in the file says who wrote it, so a `.dom`
left behind by a search run -- with a `.score` written by the NATIVE scorer
-- silently becomes a "parity" case that compares the native scorer with
itself. Three such cases were live and passing until the §39.19 objective
change made the native scorer disagree with its own stale output.
"""
for src in ORACLE_FIXTURES:
assert _ORACLE_NAME.match(src.name), src.name
stray = [p.name for p in CORPUS.glob("*.dom")
if not _ORACLE_NAME.match(p.name)
and Path(str(p) + ".score").exists()]
assert not set(stray) & {p.name for p in ORACLE_FIXTURES}, (
f"session artefacts leaked into the parity fixtures: {stray}")

View file

@ -28,7 +28,7 @@ def fake_inner(monkeypatch):
observable.""" observable."""
calls = [] calls = []
def fake_optimise(root, programme_dir, x0=None, budget=200, urb_root=None, **kw): def fake_optimise(root, programme_dir, x0=None, budget=200, **kw):
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root)) n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
fitness = 1.0 / (1.0 + abs(12 - n_leaves)) + 1e-6 * len(calls) fitness = 1.0 / (1.0 + abs(12 - n_leaves)) + 1e-6 * len(calls)
calls.append({"budget": budget, "x0": x0, "kw": kw}) calls.append({"budget": budget, "x0": x0, "kw": kw})
@ -166,7 +166,7 @@ def test_restart_keeps_elite_and_counts(monkeypatch):
"""§11.5: a stagnation restart fires, is counted, and preserves the best.""" """§11.5: a stagnation restart fires, is counted, and preserves the best."""
# Saturating fake (no monotone tiebreaker, unlike `fake_inner`): fitness # Saturating fake (no monotone tiebreaker, unlike `fake_inner`): fitness
# peaks at 12 leaves and plateaus, so the best stalls and restarts trigger. # peaks at 12 leaves and plateaus, so the best stalls and restarts trigger.
def fake_optimise(root, programme_dir, x0=None, budget=200, urb_root=None, **kw): def fake_optimise(root, programme_dir, x0=None, budget=200, **kw):
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root)) n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
fitness = 1.0 / (1.0 + abs(12 - n_leaves)) fitness = 1.0 / (1.0 + abs(12 - n_leaves))
return innerloop.Result( return innerloop.Result(
@ -289,7 +289,7 @@ def test_shapecurve_warmstart_seeds_ratios_when_eligible(monkeypatch):
divisions_at_optimise = [] divisions_at_optimise = []
def fake_optimise(root, programme_dir, x0=None, budget=200, urb_root=None, **kw): def fake_optimise(root, programme_dir, x0=None, budget=200, **kw):
divisions_at_optimise.append( divisions_at_optimise.append(
[tuple(b.division) for _, b in innerloop.free_with_keys(root)]) [tuple(b.division) for _, b in innerloop.free_with_keys(root)])
n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root)) n_leaves = sum(len(lvl.leaves()) for lvl in dom.levels(root))
@ -340,7 +340,7 @@ def test_shapecurve_warmstart_handles_multistorey(monkeypatch):
it exactly as it would a single-storey one.""" it exactly as it would a single-storey one."""
from homemaker_layout import shapecurve from homemaker_layout import shapecurve
def fake_optimise(root, programme_dir, x0=None, budget=200, urb_root=None, **kw): def fake_optimise(root, programme_dir, x0=None, budget=200, **kw):
for _, b in innerloop.free_with_keys(root): for _, b in innerloop.free_with_keys(root):
b.division = [0.25, 0.25] b.division = [0.25, 0.25]
return innerloop.Result( return innerloop.Result(
@ -364,7 +364,7 @@ def test_shapecurve_warmstart_handles_multistorey(monkeypatch):
HARBOR_L0 = Path(__file__).parent.parent / "examples" / "harbor-house-l0" HARBOR_L0 = Path(__file__).parent.parent / "examples" / "harbor-house-l0"
def _fake_optimise_ok(root, programme_dir, x0=None, budget=200, urb_root=None, **kw): def _fake_optimise_ok(root, programme_dir, x0=None, budget=200, **kw):
for _, b in innerloop.free_with_keys(root): for _, b in innerloop.free_with_keys(root):
b.division = [0.25, 0.25] b.division = [0.25, 0.25]
return innerloop.Result( return innerloop.Result(
@ -436,7 +436,7 @@ def test_shapecurve_prune_vetoes_heuristic_when_dp_feasible(monkeypatch):
pytest.skip("harbor-house-l0 not available") pytest.skip("harbor-house-l0 not available")
root = dom.load(str(HARBOR_L0 / "init.dom")) root = dom.load(str(HARBOR_L0 / "init.dom"))
ind, used = driver._evaluate( ind, used = driver._evaluate(
root, HARBOR_L0, None, x0=None, budget=100, inner_kw={}, lineage="child", root, HARBOR_L0, x0=None, budget=100, inner_kw={}, lineage="child",
feasibility_max_shape_fails=0, best_n_fails=5, leaf_sharing=False, feasibility_max_shape_fails=0, best_n_fails=5, leaf_sharing=False,
shapecurve_prune=True) shapecurve_prune=True)
@ -461,7 +461,7 @@ def test_shapecurve_prune_hard_prunes_when_dp_infeasible_and_incumbent_perfect(m
pytest.skip("harbor-house-l0 not available") pytest.skip("harbor-house-l0 not available")
root = dom.load(str(HARBOR_L0 / "init.dom")) root = dom.load(str(HARBOR_L0 / "init.dom"))
ind, used = driver._evaluate( ind, used = driver._evaluate(
root, HARBOR_L0, None, x0=None, budget=100, inner_kw={}, lineage="child", root, HARBOR_L0, x0=None, budget=100, inner_kw={}, lineage="child",
feasibility_max_shape_fails=0, best_n_fails=0, leaf_sharing=False, feasibility_max_shape_fails=0, best_n_fails=0, leaf_sharing=False,
shapecurve_prune=True) shapecurve_prune=True)
@ -485,7 +485,7 @@ def test_shapecurve_prune_defers_to_heuristic_when_incumbent_nonzero(monkeypatch
pytest.skip("harbor-house-l0 not available") pytest.skip("harbor-house-l0 not available")
root = dom.load(str(HARBOR_L0 / "init.dom")) root = dom.load(str(HARBOR_L0 / "init.dom"))
ind, used = driver._evaluate( ind, used = driver._evaluate(
root, HARBOR_L0, None, x0=None, budget=100, inner_kw={}, lineage="child", root, HARBOR_L0, x0=None, budget=100, inner_kw={}, lineage="child",
feasibility_max_shape_fails=0, best_n_fails=5, leaf_sharing=False, feasibility_max_shape_fails=0, best_n_fails=5, leaf_sharing=False,
shapecurve_prune=True) shapecurve_prune=True)
@ -670,7 +670,7 @@ def test_use_tiers_prefers_fewer_hard_over_fewer_total_fails(monkeypatch):
seed_root = dom.load(str(SEED_FILE)) seed_root = dom.load(str(SEED_FILE))
calls = [] # first call is always the seed eval; every later call is a child calls = [] # first call is always the seed eval; every later call is a child
def fake_optimise(root, programme_dir, x0=None, budget=200, urb_root=None, **kw): def fake_optimise(root, programme_dir, x0=None, budget=200, **kw):
for _, b in innerloop.free_with_keys(root): for _, b in innerloop.free_with_keys(root):
b.division = [0.25, 0.25] b.division = [0.25, 0.25]
is_seed = len(calls) == 0 is_seed = len(calls) == 0

View file

@ -1,10 +1,27 @@
"""Inner-loop search tests against a fake evaluator (no perl, no oracle).""" """Inner-loop search tests against a fake evaluator (no perl, no oracle)."""
from dataclasses import dataclass
import numpy as np import numpy as np
import pytest import pytest
from homemaker_layout import innerloop from homemaker_layout import innerloop
from homemaker_layout.oracle import Score
@dataclass
class _Score:
"""Minimal evaluator result: what the optimisers actually read.
Was `oracle.Score`; the Perl oracle is gone (DESIGN.md §39.21) and these
tests only ever needed a fitness with an empty failure set.
"""
fitness: float
fail_lines: tuple = ()
@property
def n_fails(self) -> int:
return len(self.fail_lines)
class FakeEvaluator: class FakeEvaluator:
@ -18,7 +35,7 @@ class FakeEvaluator:
def evaluate(self, xs): def evaluate(self, xs):
self.n_evals += len(xs) self.n_evals += len(xs)
self.n_oracle_calls += 1 self.n_oracle_calls += 1
return [Score(fitness=self.fn(np.asarray(x)), fails="") for x in xs] return [_Score(self.fn(np.asarray(x))) for x in xs]
def concave(x): def concave(x):

View file

@ -1,31 +0,0 @@
"""Oracle unit tests that do not invoke perl."""
import pytest
from homemaker_layout import oracle
def test_fail_lines_sorted_and_filtered():
s = oracle.Score(fitness=0.5, fails="---\nb fail\n\na fail\n \n")
assert s.fail_lines == ("a fail", "b fail")
assert s.n_fails == 2
def test_fail_lines_empty():
s = oracle.Score(fitness=0.5, fails="")
assert s.fail_lines == ()
assert s.n_fails == 0
def test_score_batch_empty_list():
assert oracle.score_batch([]) == []
def test_score_batch_rejects_cross_directory(tmp_path):
a = tmp_path / "a" / "x.dom"
b = tmp_path / "b" / "y.dom"
for p in (a, b):
p.parent.mkdir()
p.write_text("")
with pytest.raises(ValueError, match="batch spans directories"):
oracle.score_batch([a, b])