Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions scripts/clean_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,45 @@
def collect_node_files(nodes_dir: Path) -> list[dict]:
"""
Read node files (any known output extension) from a nodes directory.
Supports two filename formats:
New: {score}_{id}.{ext} e.g. 0.069113_2.svg
Old: score{score}_node{id}_... e.g. score00000.069113_node00002_parent00000.svg
Supports the shapes storage has written:
Current: {id}.{ext} e.g. 2.svg
Current: eval{score}_{id}.{ext} e.g. eval0.004392_2.svg
Legacy: {round_score}_{id}.{ext} e.g. 0.069113_2.svg
Legacy: score{score}_node{id}_... e.g. score00000.069113_node00002_...

Only the eval prefix carries a score that means anything: it is the
evaluator's, the run's only score. The legacy leading number was a blended
proxy that nothing ranked on, so it is parsed for the id and ignored.
"""
ext_pattern = "|".join(re.escape(e) for e in OUTPUT_EXTENSIONS)
# New format: plain score_id.ext
# `inf` must be its own alternative: storage writes f"{score:.6f}", which
# yields a bare "inf" for INVALID_SCORE, so requiring digits first made the
# optional (?:inf)? branch dead and left inf_*.svg files unmatched entirely.
_new = re.compile(rf"^(inf|[0-9.]+)_(\d+)(?:{ext_pattern})$")
_plain = re.compile(rf"^(\d+)(?:{ext_pattern})$")
_eval = re.compile(rf"^eval(-?[0-9.]+)_(\d+)(?:{ext_pattern})$")
_legacy = re.compile(rf"^(inf|[0-9.]+)_(\d+)(?:{ext_pattern})$")
# Old format: score00000.069113_node00002_parent00000.svg
_old = re.compile(rf"^score([0-9.]+)_node(\d+)_parent\d+(?:{ext_pattern})$")

nodes = []
for node_path in sorted(nodes_dir.iterdir()):
m = _new.match(node_path.name) or _old.match(node_path.name)
if not m:
continue
try:
score = float(m.group(1))
except ValueError:
score = float("inf")
node_id = int(m.group(2))
bare = _plain.match(node_path.name)
if bare:
score, node_id = float("inf"), int(bare.group(1))
else:
m = (
_eval.match(node_path.name)
or _legacy.match(node_path.name)
or _old.match(node_path.name)
)
if not m:
continue
try:
score = float(m.group(1))
except ValueError:
score = float("inf")
node_id = int(m.group(2))
nodes.append(
{
"id": node_id,
Expand Down
2 changes: 1 addition & 1 deletion scripts/plot_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def _float(key, default=0.0):
"seeds_target": _float("seeds_target"),
"epochs_completed": _float("epoch"),
"epoch_patience_config": _float("epoch_patience"),
"epoch_diversity_config": _float("epoch_diversity"),
"epoch_max_tasks_config": _float("epoch_max_tasks"),
"pool_diversity_final": _float("pool_diversity"),
"pool_score_std_final": _float("pool_score_std"),
}
Expand Down
34 changes: 19 additions & 15 deletions src/vectrify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@
# before going stale at task 5200. Given floors of 0.10 and 0.05, all four
# epochs of a real run ended on a pool measure rather than on staleness and the
# run stopped after 3821 of its 12000 tasks.
DEFAULT_EPOCH_DIVERSITY = 0.0
# A ceiling on how long one epoch may run before the evaluator gets to steer
# again. Off by default: the right value is a judgement about how much drift on
# the cheap measures is acceptable between evaluations, and nothing measured so
# far pins it. Measured on one run, staleness at 500 first fired 150,800 tasks
# into an epoch -- all of it with no evaluator in the loop, which is where the
# proxy runs away from what a viewer would call better.
DEFAULT_EPOCH_MAX_TASKS = None
# Tasks without improvement before an epoch is called converged. Measured over
# eleven runs and 145 improvements, the gap between one improvement and the
# next is 25 tasks at the median, 142 at the 95th percentile and 497 at the
Expand Down Expand Up @@ -221,28 +227,26 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
"so this and --seeds together fix the run's entire LLM spend. "
f"Default: {DEFAULT_EPOCHS}",
)
g_epoch.add_argument(
"--epoch-max-tasks",
type=int,
default=DEFAULT_EPOCH_MAX_TASKS,
dest="epoch_max_tasks",
metavar="N",
help="End the epoch after this many local tasks whether or not it has "
"gone stale, so the evaluator ranks the front and the model re-seeds "
"from its choice at least this often. Unset by default.",
)
g_epoch.add_argument(
"--epoch-patience",
type=int,
default=DEFAULT_EPOCH_PATIENCE,
dest="epoch_patience",
metavar="N",
help="End the epoch and re-seed if the best score does not improve by "
"reaching the best-ranked tier over this many consecutive local tasks. "
"0 disables. "
help="End the epoch and re-seed if no candidate reaches the "
"best-ranked tier over this many consecutive local tasks. 0 disables. "
f"Default: {DEFAULT_EPOCH_PATIENCE}",
)
g_epoch.add_argument(
"--epoch-diversity",
type=float,
default=DEFAULT_EPOCH_DIVERSITY,
dest="epoch_diversity",
metavar="THR",
help="End an epoch once pool diversity has fallen to this fraction "
"of what it was when the epoch opened, e.g. 0.3. A fraction rather "
"than a fixed level, because how varied a pool starts out depends on "
"the drawing. 0 disables.",
)
g_search.add_argument(
"--tournament-size",
type=int,
Expand Down
27 changes: 1 addition & 26 deletions src/vectrify/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,6 @@ def _bar(fraction: float, width: int = 12) -> str:
return "█" * filled + "░" * (width - filled)


def _diversity_color(pool_diversity: float, epoch_diversity: float) -> str:
"""Diversity keeps its own ladder — red once it has reached the stop
threshold, yellow while still within 2x of it — rather than the
fraction-based one the other criteria use."""
if epoch_diversity <= 0:
return "cyan"
if pool_diversity < epoch_diversity:
return "red"
if pool_diversity < epoch_diversity * 2:
return "yellow"
return "green"


def _threshold_color(fraction: float) -> str:
"""Green while there is headroom, red as a stop threshold is approached."""
if fraction > 0.8:
Expand Down Expand Up @@ -100,28 +87,16 @@ def _build_renderable(stats: SearchStats) -> Panel:
)

# Pool stats: single line with diversity + variance values
div_color = _diversity_color(s.pool_diversity, s.epoch_diversity)

pool_line = (
f" diversity [{div_color}]{s.pool_diversity:.3f}[/{div_color}]"
f" diversity [dim]{s.pool_diversity:.3f}[/dim]"
f" spread [dim]{s.pool_score_std:.4f}[/dim]"
f" stale [dim]{s.epoch_no_improve:,}[/dim]"
)

# Stop criteria rows (only when enabled)
stop_rows: list[tuple[str, str]] = []

if s.epoch_diversity > 0:
# Diversity's bar tracks the raw value, and its color its own ladder.
stop_rows.append(
(
"div stop",
f" [{div_color}]{_bar(s.pool_diversity, width=20)}[/{div_color}]"
f" {s.pool_diversity:.3f}"
f" [dim]epoch at < {s.epoch_diversity:.3f}[/dim]",
)
)

if s.phase == "seed" and s.seeds_target > 0:
# Not a stop criterion, but the epoch is waiting on it all the same.
stop_rows.append(
Expand Down
2 changes: 1 addition & 1 deletion src/vectrify/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def main():
epoch_patience=args.epoch_patience or None,
pool_size=args.pool_size,
seeds=args.seeds,
epoch_diversity=args.epoch_diversity,
epoch_max_tasks=args.epoch_max_tasks,
tournament_size=args.tournament_size,
adaptive_operators=args.adaptive_operators,
epochs=args.epochs,
Expand Down
64 changes: 4 additions & 60 deletions src/vectrify/score/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

from collections.abc import Mapping

from vectrify.score.moments import MOMENT_WEIGHT

EDGE = "edge"
COLOUR = "colour"
SHAPE = "shape"
Expand All @@ -21,64 +19,10 @@
# at different things.
SCORER_METRICS: tuple[str, ...] = (EDGE, COLOUR, SHAPE, DETAIL)

# What selection ranks candidates by: the chromatic and structural distances,
# blended. No embedding: the round score no longer runs a model at all.
#
# It used to, weighted half and half with colour, on the strength of a
# measurement that ranked each candidate rule by how often it agreed with the
# evaluator panel. That is only as sound as the panel, and the panel then read
# whole images, which is the thing it was worst at. Against the distortion
# screen instead -- damage of a known severity in a known order, so nothing has
# to be trusted as a reference -- the ordering is different. As a share of
# level pairs ordered correctly on vector damage:
#
# 0.25 colour + 0.50 edge 96.7% no forward pass
# edge alone 96.2% no forward pass
# colour alone 95.8% no forward pass
# 0.50 embedding + 0.50 colour 95.5% one forward pass, what shipped
# embedding at three cells 95.4% nine forward passes
# embedding whole 92.8% one forward pass
#
# Adding an embedding back to the winning pair moves it by a tenth of a point
# for four to nine forward passes per candidate, which is the whole of the
# round's model cost for nothing.
#
# Edge overlap was dropped from this blend once for scoring 41.5% against the
# panel. It is the strongest single ingredient there is when measured against
# damage that is known rather than judged.
#
# Nothing counts elements or bytes. No operator adds an element, so a measure
# built on how many there are says nothing the score does not already say.
OBJECTIVE_NAMES: tuple[str, ...] = SCORER_METRICS

# Weights within the blend, from the same sweep, which searched a grid of
# 0, 0.25 and 0.5 and picked colour at 0.25 against edge at 0.50. What it chose
# is the one-to-two ratio; the pair is written normalised so the round score is
# on the scale it appears to be on, since it is recorded per node and read back
# as an absolute number. Ranking is unaffected either way -- both this and
# build_objectives are linear in the weights, so a common factor cancels.
#
# The optimum is broad: holding edge at two thirds, colour anywhere from a
# quarter to a half of it lands within a tenth of a point.
# Colour and edge keep their one-to-two ratio; the shape term takes its weight
# from what is left. See score.moments for why it earns a place and why the
# place is a small one.
COLOUR_WEIGHT = (1.0 - MOMENT_WEIGHT) / 3.0
EDGE_WEIGHT = 2.0 * (1.0 - MOMENT_WEIGHT) / 3.0
SHAPE_WEIGHT = MOMENT_WEIGHT


def round_score(colour: float, edge: float, shape: float = 0.0) -> float:
"""What a candidate is ranked by, before the population is known.

build_objectives scales each part by its population maximum, which needs a
population; a candidate arriving on its own needs an absolute number for
the run's best-so-far and the lineage. Both measures already sit near
[0, 1], so the same weights apply unscaled.
"""
return COLOUR_WEIGHT * colour + EDGE_WEIGHT * edge + SHAPE_WEIGHT * shape


# No weights, and nothing to blend. Selection ranks by dominance over the
# vector of measures, which compares them component by component -- so no
# measure is privileged and a weight between them would not change a single
# verdict. The only score in the run is the evaluator's, below.
# The evaluator's verdict on a converged front member. Recorded so a run can be
# read back, and deliberately NOT an objective: it exists on a handful of nodes
# per epoch, and a metric present on only part of the population reads as 0.0
Expand Down
10 changes: 9 additions & 1 deletion src/vectrify/search/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
from vectrify.search.base import SearchStrategy, StorageAdapter
from vectrify.search.collector import StatCollector
from vectrify.search.engine import MultiprocessSearchEngine
from vectrify.search.models import INVALID_SCORE, ChainState, Result, SearchNode, Task
from vectrify.search.models import (
INVALID_SCORE,
VALID_SCORE,
ChainState,
Result,
SearchNode,
Task,
)
from vectrify.search.nsga import NsgaStrategy

__all__ = [
"INVALID_SCORE",
"VALID_SCORE",
"ChainState",
"MultiprocessSearchEngine",
"NsgaStrategy",
Expand Down
17 changes: 9 additions & 8 deletions src/vectrify/search/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,6 @@ def top_tier_ids(self, pool: list[SearchNode]) -> set[int]:
progress, so this replaces comparing a blended score."""
...

def should_diversify(self, pool: list[SearchNode]) -> tuple[bool, float]:
"""Return (trigger_epoch, diversity).

diversity is the mean normalised Hamming distance across sampled node pairs.
"""
...

def select_survivors(
self, nodes: list[SearchNode[TState]], max_keep: int
) -> list[SearchNode[TState]]:
Expand Down Expand Up @@ -50,7 +43,15 @@ class StorageAdapter(Protocol[TState]):

def initialize(self) -> None: ...

def save_node(self, node: SearchNode[TState], tasks_completed: int = 0) -> None: ...
def save_node(
self,
node: SearchNode[TState],
tasks_completed: int = 0,
keep_content: bool = True,
) -> None:
"""Record *node*. *keep_content* asks for the drawing itself as well as
the lineage row, which is how a run stays a readable directory."""
...

def save_best(self, node: SearchNode[TState]) -> None:
"""Write the best final candidate to the top-level output path."""
Expand Down
6 changes: 3 additions & 3 deletions src/vectrify/search/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _rounded(field: str, digits: int) -> Callable[["SearchStats"], float]:
"pool_diversity": _rounded("pool_diversity", 4),
"pool_score_std": _rounded("pool_score_std", 6),
"epoch_patience": lambda s: s.epoch_patience,
"epoch_diversity": _rounded("epoch_diversity", 4),
"epoch_max_tasks": lambda s: s.epoch_max_tasks,
}

STATS_COLUMNS = list(STATS_FIELDS)
Expand All @@ -72,10 +72,10 @@ def __init__(self, stats: "SearchStats", run_dir: Path | None = None) -> None:
def configure_run(
self,
*,
epoch_diversity: float,
epoch_max_tasks: int | None,
) -> None:
s = self._stats
s.epoch_diversity = epoch_diversity
s.epoch_max_tasks = epoch_max_tasks or 0

def seed_initial_score(self, best_score: float) -> None:
s = self._stats
Expand Down
Loading
Loading