2026-06-12 01:13:29 +01:00
|
|
|
"""Phase-1 fitness oracle: score ``.dom`` files via Urb's ``urb-fitness.pl``.
|
2026-06-10 21:49:31 +01:00
|
|
|
|
|
|
|
|
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
|
2026-06-12 01:13:29 +01:00
|
|
|
fitness to Python (Phase 3). ``urb-fitness.pl`` reads ``patterns.config`` from
|
2026-06-10 21:49:31 +01:00
|
|
|
its working directory, so the ``.dom`` must live beside the programme config.
|
2026-06-12 01:13:29 +01:00
|
|
|
|
|
|
|
|
``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 ``==``.
|
2026-06-10 21:49:31 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from pathlib import Path
|
2026-06-12 01:13:29 +01:00
|
|
|
from typing import Sequence
|
2026-06-10 21:49:31 +01:00
|
|
|
|
2026-06-17 18:40:56 +01:00
|
|
|
import yaml
|
|
|
|
|
|
2026-06-10 21:49:31 +01:00
|
|
|
DEFAULT_URB_ROOT = Path("/home/bruno/src/urb")
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 18:40:56 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 21:49:31 +01:00
|
|
|
@dataclass
|
|
|
|
|
class Score:
|
|
|
|
|
fitness: float
|
|
|
|
|
fails: str # raw .fails content (YAML and/or plain lines)
|
|
|
|
|
|
2026-06-12 01:13:29 +01:00
|
|
|
@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."""
|
2026-06-17 18:40:56 +01:00
|
|
|
return tuple(sorted(_parse_fails(self.fails)))
|
2026-06-12 01:13:29 +01:00
|
|
|
|
2026-06-10 21:49:31 +01:00
|
|
|
@property
|
|
|
|
|
def n_fails(self) -> int:
|
2026-06-12 01:13:29 +01:00
|
|
|
return len(self.fail_lines)
|
2026-06-10 21:49:31 +01:00
|
|
|
|
|
|
|
|
|
2026-06-12 01:13:29 +01:00
|
|
|
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.
|
2026-06-10 21:49:31 +01:00
|
|
|
|
2026-06-12 01:13:29 +01:00
|
|
|
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()
|
2026-06-17 18:40:56 +01:00
|
|
|
env = {**os.environ, "DEBUG": "1", "URB_NO_OCCLUSION": "1"}
|
2026-06-10 21:49:31 +01:00
|
|
|
proc = subprocess.run(
|
2026-06-12 01:13:29 +01:00
|
|
|
["perl", f"-I{urb_root}/lib", str(urb_root / "bin" / "urb-fitness.pl")]
|
|
|
|
|
+ [p.name for p in paths],
|
|
|
|
|
cwd=cwd,
|
2026-06-10 21:49:31 +01:00
|
|
|
env=env,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
2026-06-12 01:13:29 +01:00
|
|
|
|
|
|
|
|
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 "",
|
|
|
|
|
)
|
2026-06-10 21:49:31 +01:00
|
|
|
)
|
2026-06-12 01:13:29 +01:00
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def score(dom_path: str | Path, urb_root: str | Path = DEFAULT_URB_ROOT) -> Score:
|
|
|
|
|
return score_batch([dom_path], urb_root)[0]
|