homemaker-layout/src/homemaker_layout/oracle.py

101 lines
3.6 KiB
Python
Raw Normal View History

"""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
DEFAULT_URB_ROOT = Path("/home/bruno/src/urb")
@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(line.strip() for line in self.fails.splitlines() if line.strip() and line.strip() != "---")
)
@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"}
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]