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
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,17 @@ Three normalized objectives are minimized in parallel:
| structural complexity | code size (whitespace-stripped source length) |

Each is scaled by its own maximum across the current pool, so the three
are directly comparable and no weighting between them is needed NSGA
are directly comparable and no weighting between them is needed. NSGA
trades them off by dominance alone. The selection machinery itself is
arity-agnostic: dominance, crowding distance, and the Pareto helpers all
read the objective count off the data rather than assuming it. Adding a
fourth objective still means threading the new measure through the
worker, node model, and run artifacts, but the algorithm needs no
changes.

Be sparing about it, though. Dominance dilutes as objectives multiply
on a 12-node pool the first front grows from 8 nodes at three objectives
to 11 at five so past about four, nearly everything is non-dominated
Be sparing about it, though. Dominance dilutes as objectives multiply.
On a 12-node pool the first front grows from 8 nodes at three objectives
to 11 at five, so past about four, nearly everything is non-dominated
and the front stops discriminating.

The constraint-first variant (Deb 2000) gates on visual error: a
Expand All @@ -158,12 +158,23 @@ complexity measures act as tiebreakers among the quality-leaders,
biasing toward small, clean renderings instead of accreting detail
forever once the image is already close.

The median split (`FEASIBLE_FRACTION`) is chosen rather than something
stricter because a tighter gate is not automatically a stronger one: if
the feasible group is very small, most binary-tournament comparisons are
between two infeasible candidates, where the gate contributes nothing.
Splitting at the median maximises the share of comparisons the gate
actually decides.
The median split is chosen rather than something stricter because a
tighter gate is not automatically a stronger one: if the feasible group
is very small, most binary-tournament comparisons are between two
infeasible candidates, where the gate contributes nothing. Splitting at
the median maximizes the share of comparisons the gate actually decides.

If you want to push harder toward visual quality, `--tournament-size` is
the stronger lever by a wide margin. Parents are chosen by tournament,
and the winner's expected quality rises steeply with the number of
candidates compared. On a 20-node pool, the share of parents drawn from
the better-scoring half runs about 81% at the default of 2, 92% at 3, and
97% at 4. It is an absolute count rather than a fraction of the pool,
because selection intensity depends only on the tournament size, so the
same value means the same thing at any pool size. Note the two are not
perfectly orthogonal: at a very small pool the same size bites harder.
Raising it converges faster but spends pool diversity, which also brings
`--epoch-diversity` transitions forward.

Structural complexity is deliberately format-agnostic, so it means the
same thing for SVG, DOT and Typst and no backend is scored as free. It
Expand Down
5 changes: 4 additions & 1 deletion scripts/clean_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ def collect_node_files(nodes_dir: Path) -> list[dict]:
"""
ext_pattern = "|".join(re.escape(e) for e in OUTPUT_EXTENSIONS)
# New format: plain score_id.ext
_new = re.compile(rf"^([0-9.]+(?:inf)?)_(\d+)(?:{ext_pattern})$")
# `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})$")
# Old format: score00000.069113_node00002_parent00000.svg
_old = re.compile(rf"^score([0-9.]+)_node(\d+)_parent\d+(?:{ext_pattern})$")

Expand Down
32 changes: 30 additions & 2 deletions src/vectrify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
DEFAULT_SCORER = "auto"
DEFAULT_STRATEGY = "nsga"
BEAM_ONLY_PARAMS = {"beams", "cull_keep"}
NSGA_ONLY_PARAMS = {"epoch_diversity", "epoch_variance", "epoch_seeds"}
NSGA_ONLY_PARAMS = {
"epoch_diversity",
"epoch_variance",
"epoch_seeds",
"tournament_size",
}
DEFAULT_MAX_EPOCHS = 4
DEFAULT_WORKERS = os.cpu_count() or 4
DEFAULT_MAX_WALL_SECONDS = 60 * 60
Expand Down Expand Up @@ -41,6 +46,7 @@ def _default_llm_rate(workers: int) -> float:
DEFAULT_EPOCH_PATIENCE = 20
DEFAULT_EPOCH_MIN_DELTA = 1e-4
DEFAULT_EPOCH_STEPS = 50
DEFAULT_TOURNAMENT_SIZE = 2
DEFAULT_MAX_LLM_CALLS = 0 # 0 = unlimited / off
DEFAULT_MAX_TOTAL_TASKS = 10000
DEFAULT_FORMAT = "svg"
Expand Down Expand Up @@ -277,6 +283,17 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
help="[nsga-only] End epoch when score std dev in the active pool "
"drops below this threshold. 0 disables.",
)
g_search.add_argument(
"--tournament-size",
type=int,
default=DEFAULT_TOURNAMENT_SIZE,
dest="tournament_size",
metavar="N",
help="[nsga-only] Candidates compared per parent-selection tournament. "
"Higher means stronger bias toward visual quality and faster "
"convergence, at the cost of pool diversity. "
f"Default: {DEFAULT_TOURNAMENT_SIZE}",
)
g_epoch.add_argument(
"--epoch-seeds",
type=int,
Expand Down Expand Up @@ -409,6 +426,9 @@ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
if ns.llm_rate is None:
ns.llm_rate = _default_llm_rate(ns.workers)

if ns.tournament_size < 2:
raise SystemExit("Error: --tournament-size must be at least 2")

if not (0.0 < ns.cull_keep <= 1.0):
raise SystemExit("Error: --cull-keep must be greater than 0.0 and at most 1.0")

Expand All @@ -420,8 +440,16 @@ def _flags(params):

is_beam = ns.strategy == StrategyType.BEAM.value
if is_beam:
nsga_defaults = {
"epoch_diversity": DEFAULT_EPOCH_DIVERSITY,
"epoch_variance": DEFAULT_EPOCH_VARIANCE,
"epoch_seeds": DEFAULT_EPOCH_SEEDS,
"tournament_size": DEFAULT_TOURNAMENT_SIZE,
}
nsga_set = {
p for p in NSGA_ONLY_PARAMS if getattr(ns, p, None) not in (None, 0, 0.0)
p
for p in NSGA_ONLY_PARAMS
if getattr(ns, p, None) not in (None, nsga_defaults[p])
}
if nsga_set:
raise SystemExit(
Expand Down
26 changes: 16 additions & 10 deletions src/vectrify/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ def _diversity_color(pool_diversity: float, epoch_diversity: float) -> str:
return "green"


def _variance_fraction(epoch_variance: float, pool_score_std: float) -> float:
"""Progress toward the low-variance epoch-end criterion, in [0, 1].

Ratio of threshold to current spread, so the bar fills as the pool collapses
toward the stop. Zero spread is the most collapsed the pool can be -- the
criterion is already satisfied -- so that reads full rather than empty.
"""
if epoch_variance <= 0:
return 0.0
if pool_score_std <= 0:
return 1.0
return min(1.0, epoch_variance / pool_score_std)


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,16 +114,8 @@ def _build_renderable(stats: SearchStats) -> Panel:
# Pool stats: single line with diversity + variance values
div_color = _diversity_color(s.pool_diversity, s.epoch_diversity)

if s.epoch_variance > 0:
var_frac = (
min(1.0, s.epoch_variance / s.pool_score_std)
if s.pool_score_std > 0
else 0.0
)
var_color = _threshold_color(var_frac)
else:
var_frac = 0.0
var_color = "cyan"
var_frac = _variance_fraction(s.epoch_variance, s.pool_score_std)
var_color = _threshold_color(var_frac) if s.epoch_variance > 0 else "cyan"

pool_line = (
f" diversity [{div_color}]{s.pool_diversity:.3f}[/{div_color}]"
Expand Down
37 changes: 33 additions & 4 deletions src/vectrify/formats/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import logging
import re
from typing import TYPE_CHECKING, Protocol

Expand All @@ -8,25 +9,53 @@
if TYPE_CHECKING:
import PIL.Image

log = logging.getLogger(__name__)

_SEARCH_REPLACE_RE = re.compile(
r"<<<SEARCH>>>\n(.*?)\n<<<REPLACE>>>\n(.*?)\n<<<END>>>",
re.DOTALL,
)


class NoEditAppliedError(ValueError):
"""Raised when diff blocks were present but none matched the parent."""


def apply_search_replace(parent: str, raw: str) -> str | None:
"""Apply search/replace blocks from *raw* onto *parent*.

Returns the patched string, or ``None`` if *raw* contained no blocks.
Blocks are applied in order; each replaces the first occurrence in the
current (already-patched) text.
Returns the patched string, or ``None`` if *raw* contained no blocks at all
-- that is the signal for callers to fall back to parsing a whole file out
of the response. Blocks are applied in order; each replaces the first
occurrence in the current (already-patched) text.

Raises NoEditAppliedError if blocks were present but none of their SEARCH
text was found. ``str.replace`` is silent in that case, so the parent used
to come back unchanged and be reported as a successful edit: a paid LLM call
produced a byte-identical child that still entered the pool, carrying its
parent's signature and dragging the measured genome diversity down until it
tripped an epoch transition.
"""
blocks = _SEARCH_REPLACE_RE.findall(raw)
if not blocks:
return None

result = parent
applied = 0
for search, replace in blocks:
result = result.replace(search, replace, 1)
if search in result:
result = result.replace(search, replace, 1)
applied += 1

if applied == 0:
raise NoEditAppliedError(
f"none of the {len(blocks)} search/replace block(s) matched the parent"
)
if applied < len(blocks):
log.warning(
f"Applied {applied}/{len(blocks)} search/replace blocks; "
"the rest did not match the parent."
)
return result


Expand Down
17 changes: 16 additions & 1 deletion src/vectrify/formats/graphviz/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,21 @@ def _rasterize_dot(dot: str) -> bytes | None:
_ATTR_BLOCK_RE = re.compile(r"^\s*(?:node|edge|graph)\s*\[[^\]]*\];?\s*$", re.MULTILINE)


def _insert_after_first_brace(dot: str, block: str) -> str:
"""Splice *block* in just after the graph's opening brace.

Done with slicing rather than re.sub because *block* is lifted verbatim from
another candidate: as an re replacement template its backslashes would be
interpreted, and \\l, \\r and \\N are ordinary Graphviz label escapes, so
`bad escape \\l` would abort the crossover.
"""
brace = dot.find("{")
if brace == -1:
return dot
cut = brace + 1
return f"{dot[:cut]}\n{block}{dot[cut:]}"


def mutate_with_micro_search(
parent_dot: str,
orig_img_fast: Image.Image,
Expand Down Expand Up @@ -232,7 +247,7 @@ def crossover_with_micro_search(

def _op() -> tuple[str, str]:
attr = random.choice(attrs_b)
return re.sub(r"(\{)", r"\1\n" + attr, dot_a, count=1), "Crossover: attributes"
return _insert_after_first_brace(dot_a, attr), "Crossover: attributes"

return with_micro_search(
_op,
Expand Down
24 changes: 17 additions & 7 deletions src/vectrify/formats/typst/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ def _mutate_color(typst_code: str) -> str:
return typst_code[:start] + new_color + typst_code[end:]


def _split_lines(typst_code: str) -> list[str]:
"""Split into lines that each end with a newline.

Splicing with ``keepends=True`` alone fuses two elements onto one physical
line whenever the moved line is the last one and lacks a trailing newline.
A fused line then no longer matches _ELEMENT_LINE_RE, which is anchored per
line, so the hidden element becomes unreachable by every later mutation.
"""
return [
line if line.endswith("\n") else line + "\n"
for line in typst_code.splitlines(keepends=True)
]


def _remove_element(typst_code: str) -> str:
"""Remove a random shape element line, keeping at least one."""
lines = typst_code.splitlines(keepends=True)
Expand All @@ -92,7 +106,7 @@ def _remove_element(typst_code: str) -> str:

def _reorder_elements(typst_code: str) -> str:
"""Swap two element lines to change rendering order."""
lines = typst_code.splitlines(keepends=True)
lines = _split_lines(typst_code)
element_indices = [
i for i, line in enumerate(lines) if _ELEMENT_LINE_RE.match(line)
]
Expand Down Expand Up @@ -162,12 +176,8 @@ def crossover_with_micro_search(
num_trials: int = 15,
) -> tuple[str, str]:
# Extract non-empty element lines from B to inject into A
lines_b = [
line
for line in code_b.splitlines(keepends=True)
if _ELEMENT_LINE_RE.match(line)
]
lines_a = code_a.splitlines(keepends=True)
lines_b = [line for line in _split_lines(code_b) if _ELEMENT_LINE_RE.match(line)]
lines_a = _split_lines(code_a)
element_indices_a = [
i for i, line in enumerate(lines_a) if _ELEMENT_LINE_RE.match(line)
]
Expand Down
2 changes: 2 additions & 0 deletions src/vectrify/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def main():
resume_top=args.resume_top,
save_raster=args.save_raster,
save_heatmap=args.save_heatmap,
write_lineage=args.write_lineage,
)

use_dashboard = args.dashboard and sys.stdout.isatty()
Expand Down Expand Up @@ -137,6 +138,7 @@ def main():
beams=args.beams,
cull_keep=args.cull_keep,
epoch_diversity=args.epoch_diversity,
tournament_size=args.tournament_size,
epoch_variance=args.epoch_variance or None,
max_epochs=args.max_epochs,
epoch_pool_size=args.epoch_seeds or None,
Expand Down
Loading
Loading