diff --git a/README.md b/README.md index 4c6de81..0e2ec5c 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ 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 @@ -145,9 +145,9 @@ 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 @@ -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 diff --git a/scripts/clean_runs.py b/scripts/clean_runs.py index 222d407..6738324 100755 --- a/scripts/clean_runs.py +++ b/scripts/clean_runs.py @@ -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})$") diff --git a/src/vectrify/cli.py b/src/vectrify/cli.py index 06ea995..e6949a1 100644 --- a/src/vectrify/cli.py +++ b/src/vectrify/cli.py @@ -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 @@ -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" @@ -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, @@ -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") @@ -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( diff --git a/src/vectrify/dashboard.py b/src/vectrify/dashboard.py index 9b5aa80..97b3209 100644 --- a/src/vectrify/dashboard.py +++ b/src/vectrify/dashboard.py @@ -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: @@ -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}]" diff --git a/src/vectrify/formats/base.py b/src/vectrify/formats/base.py index a6b5849..7a03fff 100644 --- a/src/vectrify/formats/base.py +++ b/src/vectrify/formats/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import re from typing import TYPE_CHECKING, Protocol @@ -8,25 +9,53 @@ if TYPE_CHECKING: import PIL.Image +log = logging.getLogger(__name__) + _SEARCH_REPLACE_RE = re.compile( r"<<>>\n(.*?)\n<<>>\n(.*?)\n<<>>", 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 diff --git a/src/vectrify/formats/graphviz/operations.py b/src/vectrify/formats/graphviz/operations.py index ec78128..ee89cd2 100644 --- a/src/vectrify/formats/graphviz/operations.py +++ b/src/vectrify/formats/graphviz/operations.py @@ -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, @@ -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, diff --git a/src/vectrify/formats/typst/operations.py b/src/vectrify/formats/typst/operations.py index d62018c..8dff95f 100644 --- a/src/vectrify/formats/typst/operations.py +++ b/src/vectrify/formats/typst/operations.py @@ -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) @@ -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) ] @@ -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) ] diff --git a/src/vectrify/main.py b/src/vectrify/main.py index 10680f7..55040d3 100755 --- a/src/vectrify/main.py +++ b/src/vectrify/main.py @@ -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() @@ -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, diff --git a/src/vectrify/search/engine.py b/src/vectrify/search/engine.py index 1ce9799..ef37ade 100644 --- a/src/vectrify/search/engine.py +++ b/src/vectrify/search/engine.py @@ -126,6 +126,7 @@ def _scorer_worker(): tasks_completed = 0 accepted_count = 0 in_flight = 0 + last_invalid_msg = "unknown error" next_node_id = max( self.storage.max_node_id, max((n.id for n in initial_nodes), default=0) @@ -354,7 +355,7 @@ def _check_epoch_end(): low_variance = ( epoch_variance is not None and epoch_variance > 0 - and score_std < epoch_variance + and pool_std < epoch_variance ) if staleness or steps_exhausted or low_diversity or low_variance: @@ -370,7 +371,7 @@ def _check_epoch_end(): elif low_diversity: reason = f"low diversity ({pool_diversity:.4f})" else: - reason = f"low variance ({score_std:.6f} < {epoch_variance})" + reason = f"low variance ({pool_std:.6f} < {epoch_variance})" if llm_in_flight == 0: epoch_drain_reason = reason @@ -435,6 +436,7 @@ def _check_epoch_end(): ) if not res.valid: + last_invalid_msg = res.invalid_msg or "unknown error" if res.llm_type: log.info( f"[{res.llm_type.upper()} INVALID] " @@ -444,6 +446,22 @@ def _check_epoch_end(): log.debug(f"Task {res.task_id} rejected: {res.invalid_msg}") if collector is not None: collector.on_invalid(res) + + # _dispatch_tasks stalls in epoch 0 until something is + # accepted, so if every seed failed nothing would ever be + # dispatched again and the run would idle until the wall + # clock. Surface the underlying failure instead. + if ( + epoch == 0 + and seed_tasks > 0 + and accepted_count == 0 + and epoch0_seeds_completed >= seed_tasks + and in_flight == 0 + ): + raise RuntimeError( + f"All {seed_tasks} epoch-0 seed task(s) failed and no " + f"candidate was accepted; last error: {last_invalid_msg}" + ) continue _process_valid_result(res) @@ -451,8 +469,13 @@ def _check_epoch_end(): finally: if best_node is not None: - with contextlib.suppress(Exception): + # Still swallowed so a save failure cannot mask an in-flight + # exception during shutdown, but never silently: this is the + # run's single most important artifact. + try: self.storage.save_best(best_node) + except Exception as e: + log.error(f"Failed to write the best candidate: {e!r}") if collector is not None: collector.on_shutdown() self._shutdown() diff --git a/src/vectrify/search/nsga.py b/src/vectrify/search/nsga.py index 8b32149..02453a9 100644 --- a/src/vectrify/search/nsga.py +++ b/src/vectrify/search/nsga.py @@ -225,10 +225,16 @@ def __init__( pool_size: int = 20, crossover_distance_threshold: int = 10, epoch_diversity: float = 0.0, + tournament_size: int = 2, ): self.pool_size = pool_size self.crossover_distance_threshold = crossover_distance_threshold self.epoch_diversity = epoch_diversity + # Selection intensity is a function of the tournament size alone -- the + # winner's expected quantile is ~1/(size+1) -- so this is an absolute + # count rather than a fraction of the pool, and stays meaningful when + # pool_size changes. + self.tournament_size = max(2, tournament_size) def _is_duplicate( self, node: SearchNode[TState], other: SearchNode[TState] @@ -268,12 +274,10 @@ def _tournament(exclude_id: int | None = None) -> SearchNode[TState]: candidates = [n for n in pool if n.id != exclude_id] if len(candidates) < 2: return candidates[0] if candidates else pool[0] - a, b = random.sample(candidates, 2) - ra, da = rank[a.id], crowd[a.id] - rb, db = rank[b.id], crowd[b.id] - if ra < rb or (ra == rb and da > db): - return a - return b + sample = random.sample( + candidates, min(self.tournament_size, len(candidates)) + ) + return min(sample, key=lambda n: (rank[n.id], -crowd[n.id])) p1 = _tournament() if len(pool) >= 2: diff --git a/src/vectrify/tests/formats/graphviz/test_operations.py b/src/vectrify/tests/formats/graphviz/test_operations.py index 4d16d67..aa66788 100644 --- a/src/vectrify/tests/formats/graphviz/test_operations.py +++ b/src/vectrify/tests/formats/graphviz/test_operations.py @@ -92,3 +92,40 @@ def test_crossover_falls_back_to_mutation_when_no_attrs_in_b(): target = Image.new("RGB", (32, 32), color="red") result, _summary = crossover_with_micro_search(_DOT, dot_b, target, num_trials=3) assert isinstance(result, str) + + +def test_crossover_survives_backslash_label_escapes(): + """Regression: the donor attribute block was concatenated into an re.sub + *replacement template*, so its backslashes were interpreted. \\l, \\r and + \\N are ordinary Graphviz label escapes, and the crossover died with + `re.error: bad escape \\l`, silently failing the task. + """ + from PIL import Image + + from vectrify.formats.graphviz.operations import crossover_with_micro_search + + donor = ( + 'digraph G {\n node [shape=box, label="left\\lright\\l"];\n a -> b;\n}' + ) + target = "digraph G {\n a -> b;\n}" + ref = Image.new("RGB", (64, 64), "white") + + content, summary = crossover_with_micro_search(target, donor, ref, num_trials=3) + + assert isinstance(content, str) + assert isinstance(summary, str) + + +def test_insert_after_first_brace_keeps_escapes_literal(): + from vectrify.formats.graphviz.operations import _insert_after_first_brace + + block = 'node [label="a\\lb", tooltip="c\\Nd"];' + out = _insert_after_first_brace("digraph G {\n a -> b;\n}", block) + assert block in out # verbatim, not re-interpreted + assert out.index(block) > out.index("{") + + +def test_insert_after_first_brace_without_a_brace_is_a_noop(): + from vectrify.formats.graphviz.operations import _insert_after_first_brace + + assert _insert_after_first_brace("not a graph", "node [];") == "not a graph" diff --git a/src/vectrify/tests/formats/test_base_plugin.py b/src/vectrify/tests/formats/test_base_plugin.py index c6d2c24..e439a4c 100644 --- a/src/vectrify/tests/formats/test_base_plugin.py +++ b/src/vectrify/tests/formats/test_base_plugin.py @@ -3,6 +3,7 @@ import io +import pytest from PIL import Image from vectrify.formats.base import BaseFormatPlugin @@ -56,3 +57,73 @@ def test_apply_edit_applies_search_replace_blocks(): def test_apply_edit_falls_back_to_extraction_without_blocks(): result = _StubPlugin().apply_edit("parent", " a full replacement ") assert result == "a full replacement" + + +def test_apply_search_replace_raises_when_no_block_matches(): + """Regression: str.replace is silent on a miss, so a hallucinated SEARCH + block returned the parent unchanged and was reported as a successful edit. + That byte-identical child then entered the pool carrying its parent's + signature, dragging measured diversity toward zero. + """ + from vectrify.formats.base import NoEditAppliedError, apply_search_replace + + parent = '' + raw = ( + "<<>>\n" + '\n' + "<<>>\n" + '\n' + "<<>>" + ) + with pytest.raises(NoEditAppliedError): + apply_search_replace(parent, raw) + + +def test_apply_search_replace_returns_none_when_there_are_no_blocks(): + """Distinct from a failed match: None is the signal to fall back to parsing + a whole file out of the response, and must keep working. + """ + from vectrify.formats.base import apply_search_replace + + assert apply_search_replace("", "here is some prose") is None + + +def test_apply_search_replace_applies_a_matching_block(): + from vectrify.formats.base import apply_search_replace + + parent = '' + raw = ( + "<<>>\n" + '\n' + "<<>>\n" + '\n' + "<<>>" + ) + assert apply_search_replace(parent, raw) == '' + + +def test_apply_search_replace_allows_partial_application(caplog): + """One block matching and another not is not a duplicate, so it is applied + and warned about rather than rejected. + """ + import logging + + from vectrify.formats.base import apply_search_replace + + parent = '' + raw = ( + "<<>>\n" + '\n' + "<<>>\n" + '\n' + "<<>>\n" + "<<>>\n" + '\n' + "<<>>\n" + '\n' + "<<>>" + ) + with caplog.at_level(logging.WARNING): + out = apply_search_replace(parent, raw) + assert 'fill="blue"' in out + assert "1/2" in caplog.text diff --git a/src/vectrify/tests/formats/typst/test_operations.py b/src/vectrify/tests/formats/typst/test_operations.py index 2513e0f..29163bb 100644 --- a/src/vectrify/tests/formats/typst/test_operations.py +++ b/src/vectrify/tests/formats/typst/test_operations.py @@ -202,3 +202,62 @@ def test_crossover_falls_back_to_mutation_when_no_elements_in_b(): target = Image.new("RGB", (32, 32), color="red") result, _ = crossover_with_micro_search(_TYPST_CODE, code_b, target, num_trials=3) assert "#set page" in result + + +def test_reorder_keeps_every_element_addressable_without_trailing_newline(): + """Regression: splicing with keepends=True fused two elements onto one line + when the moved line was last and lacked a newline. _ELEMENT_LINE_RE is + anchored per line, so the fused element became invisible to every later + mutation -- permanently losing a mutable site. + """ + import random + + from vectrify.formats.typst.operations import _ELEMENT_LINE_RE, _reorder_elements + + code = "#set page(width: 100pt)\n#rect(width: 10pt)\n#circle(radius: 5pt)" + assert len(_ELEMENT_LINE_RE.findall(code)) == 2 + + random.seed(0) + changed = None + for _ in range(20): + out = _reorder_elements(code) + if out != code: + changed = out + break + + assert changed is not None, "reorder never produced a change" + assert len(_ELEMENT_LINE_RE.findall(changed)) == 2 + assert "5pt)#rect" not in changed + + +def test_crossover_keeps_every_element_addressable(): + from PIL import Image + + from vectrify.formats.typst.operations import ( + _ELEMENT_LINE_RE, + crossover_with_micro_search, + ) + + a = "#set page(width: 100pt)\n#rect(width: 10pt)\n#circle(radius: 5pt)" + b = "#set page(width: 100pt)\n#polygon()\n#line(length: 9pt)" # last line bare + ref = Image.new("RGB", (64, 64), "white") + + content, _ = crossover_with_micro_search(a, b, ref, num_trials=4) + + for line in content.splitlines(): + assert ( + line.count("#rect(") + + line.count("#circle(") + + line.count("#line(") + + line.count("#polygon(") + <= 1 + ), f"elements fused onto one line: {line!r}" + assert _ELEMENT_LINE_RE.findall(content) + + +def test_split_lines_normalizes_the_final_newline(): + from vectrify.formats.typst.operations import _split_lines + + assert _split_lines("a\nb") == ["a\n", "b\n"] + assert _split_lines("a\nb\n") == ["a\n", "b\n"] + assert _split_lines("") == [] diff --git a/src/vectrify/tests/search/test_engine.py b/src/vectrify/tests/search/test_engine.py index a9f0693..2d69b9b 100644 --- a/src/vectrify/tests/search/test_engine.py +++ b/src/vectrify/tests/search/test_engine.py @@ -328,3 +328,66 @@ def test_engine_score_fn_none_with_unscored_result_raises(): max_wall_seconds=None, score_fn=None, ) + + +def test_engine_low_variance_epoch_end_does_not_crash(): + """Regression: the low-variance branch compared the imported score_std + *function* to a float, so any positive --epoch-variance raised TypeError on + the first epoch-end check. Nothing covered this path, which is why it + shipped. + """ + strat = FakeStrategy() + store = FakeStorage() + engine = MultiprocessSearchEngine( + workers=1, strategy=strat, storage=store, max_total_tasks=1 + ) + engine.unscored_q.put( + Result(task_id=1, parent_id=1, valid=True, score=0.1, payload="p") + ) + initial = SearchNode( + score=0.8, id=1, parent_id=0, state=ChainState(score=0.8, payload=None) + ) + + engine.run( + initial_nodes=[initial], + max_wall_seconds=None, + epoch_variance=0.05, # the flag that used to crash the run + active_pool_size=1, + ) + + assert store.save_called is True + + +def test_engine_aborts_when_every_epoch0_seed_fails(): + """Regression: _dispatch_tasks stalls in epoch 0 until something is + accepted, so if every seed failed the run idled until --max-wall-seconds + and exited 0 with no output. It must surface the underlying error instead. + """ + engine = MultiprocessSearchEngine( + workers=1, strategy=FakeStrategy(), storage=FakeStorage(), max_total_tasks=50 + ) + engine.unscored_q.put( + Result( + task_id=1, + parent_id=1, + valid=False, + score=float("inf"), + payload=None, + invalid_msg="AuthenticationError(401)", + llm_type="llm-generate", + ) + ) + initial = SearchNode( + score=float("inf"), + id=1, + parent_id=0, + state=ChainState(score=float("inf"), payload=None), + ) + + with pytest.raises(RuntimeError, match="seed task"): + engine.run( + initial_nodes=[initial], + max_wall_seconds=None, + seed_tasks=1, + active_pool_size=1, + ) diff --git a/src/vectrify/tests/search/test_nsga.py b/src/vectrify/tests/search/test_nsga.py index e505b08..00c8f36 100644 --- a/src/vectrify/tests/search/test_nsga.py +++ b/src/vectrify/tests/search/test_nsga.py @@ -548,3 +548,60 @@ def test_non_dominated_sort_no_threshold_simple_dominates_complex(): fronts = non_dominated_sort([n1, n2], objectives) assert len(fronts) == 1 assert {n.id for n in fronts[0]} == {1, 2} + + +def test_tournament_size_defaults_to_two(): + assert NsgaStrategy().tournament_size == 2 + + +def test_tournament_size_is_clamped_to_a_usable_minimum(): + """A tournament of one is not a tournament -- it would select uniformly at + random and silently remove all selection pressure.""" + assert NsgaStrategy(tournament_size=1).tournament_size == 2 + assert NsgaStrategy(tournament_size=0).tournament_size == 2 + + +def test_tournament_size_larger_than_the_pool_is_safe(): + """random.sample raises if asked for more items than exist.""" + strategy = NsgaStrategy(pool_size=10, tournament_size=50) + nodes = [make_node(i, i * 0.1) for i in range(1, 4)] + pid, _ = strategy.select_parent(nodes) + assert pid in {n.id for n in nodes} + + +def test_tournament_size_of_one_node_pool_is_safe(): + strategy = NsgaStrategy(tournament_size=8) + nodes = [make_node(1, 0.5)] + assert strategy.select_parent(nodes) == (1, None) + + +def test_larger_tournament_biases_harder_toward_score(): + """Selection intensity rises with tournament size; this is the lever that + keeps visual error primary, far more than the feasibility gate does. + """ + import random as _random + + def better_half_rate(size: int, trials: int = 1500) -> float: + strategy = NsgaStrategy( + pool_size=20, crossover_distance_threshold=999, tournament_size=size + ) + _random.seed(7) + hits = 0 + for _ in range(trials): + nodes = [ + make_node( + i, + _random.random(), + _random.random() * 5000, + content=f"n{i}-{_random.random()}", + structural_complexity=_random.random() * 5000, + ) + for i in range(20) + ] + median = sorted(n.score for n in nodes)[10] + pid, _secondary = strategy.select_parent(nodes) + if next(n for n in nodes if n.id == pid).score <= median: + hits += 1 + return hits / trials + + assert better_half_rate(4) > better_half_rate(2) > 0.5 diff --git a/src/vectrify/tests/test_cli.py b/src/vectrify/tests/test_cli.py index 8b91e9c..faff7f0 100644 --- a/src/vectrify/tests/test_cli.py +++ b/src/vectrify/tests/test_cli.py @@ -115,3 +115,33 @@ def test_max_epochs_zero_raises(): def test_max_epochs_negative_raises(): with pytest.raises(SystemExit): parse_args(["img.png", "--max-epochs", "-1"]) + + +def test_tournament_size_defaults_to_two(): + from vectrify.cli import DEFAULT_TOURNAMENT_SIZE + + args = parse_args(["in.png"]) + assert args.tournament_size == DEFAULT_TOURNAMENT_SIZE == 2 + + +def test_tournament_size_is_accepted(): + args = parse_args(["in.png", "--tournament-size", "4"]) + assert args.tournament_size == 4 + + +def test_tournament_size_below_two_is_rejected(): + with pytest.raises(SystemExit): + parse_args(["in.png", "--tournament-size", "1"]) + + +def test_tournament_size_is_nsga_only(): + with pytest.raises(SystemExit): + parse_args(["in.png", "--strategy", "beam", "--tournament-size", "4"]) + + +def test_default_tournament_size_does_not_trip_the_beam_check(): + """The nsga-only guard must compare against the default, not against zero -- + a default of 2 would otherwise look 'set' and break every beam run. + """ + args = parse_args(["in.png", "--strategy", "beam"]) + assert args.strategy == "beam" diff --git a/src/vectrify/tests/test_dashboard.py b/src/vectrify/tests/test_dashboard.py new file mode 100644 index 0000000..5b79733 --- /dev/null +++ b/src/vectrify/tests/test_dashboard.py @@ -0,0 +1,29 @@ +import pytest + +from vectrify.dashboard import _variance_fraction + + +@pytest.mark.parametrize( + ("epoch_variance", "pool_std", "expected"), + [ + (0.0, 0.5, 0.0), # criterion disabled + (0.1, 1.0, 0.1), # far from the stop + (0.1, 0.2, 0.5), # halfway + (0.1, 0.1, 1.0), # exactly at the threshold + (0.1, 0.05, 1.0), # past it, clamped + ], +) +def test_variance_fraction(epoch_variance, pool_std, expected): + assert _variance_fraction(epoch_variance, pool_std) == pytest.approx(expected) + + +def test_variance_fraction_is_full_at_zero_spread(): + """Regression: zero spread returned 0.0, so the bar read empty at exactly + the moment the criterion was most satisfied. A pool whose scores are all + identical is the collapsed state --epoch-variance exists to detect. + """ + assert _variance_fraction(0.1, 0.0) == 1.0 + + +def test_variance_fraction_stays_zero_when_disabled_even_at_zero_spread(): + assert _variance_fraction(0.0, 0.0) == 0.0 diff --git a/src/vectrify/tests/test_utils.py b/src/vectrify/tests/test_utils.py new file mode 100644 index 0000000..dbdf563 --- /dev/null +++ b/src/vectrify/tests/test_utils.py @@ -0,0 +1,39 @@ +import logging + +from vectrify.utils import setup_logger + + +def _handler_types() -> set[str]: + return {type(h).__name__ for h in logging.getLogger().handlers} + + +def test_console_only_without_a_log_file(): + setup_logger("INFO") + assert "StreamHandler" in _handler_types() + + +def test_log_file_is_added_alongside_the_console(tmp_path): + """Regression: a log file *replaced* the stderr handler. runner.py calls + this again once the run dir exists, so piped runs lost every later message + -- including 'no valid candidate found' and 'best candidate written'. + """ + setup_logger("INFO", log_file=tmp_path / "search.log") + kinds = _handler_types() + assert "FileHandler" in kinds + assert "StreamHandler" in kinds + + +def test_console_can_be_suppressed_for_the_dashboard(tmp_path): + setup_logger("INFO", log_file=tmp_path / "search.log", console=False) + kinds = _handler_types() + assert "FileHandler" in kinds + assert "StreamHandler" not in kinds + + +def test_log_file_actually_receives_records(tmp_path): + path = tmp_path / "search.log" + setup_logger("INFO", log_file=path) + logging.getLogger("main").info("hello from the run") + for h in logging.getLogger().handlers: + h.flush() + assert "hello from the run" in path.read_text(encoding="utf-8") diff --git a/src/vectrify/tests/vector/test_storage.py b/src/vectrify/tests/vector/test_storage.py index 5790892..cde1cde 100644 --- a/src/vectrify/tests/vector/test_storage.py +++ b/src/vectrify/tests/vector/test_storage.py @@ -265,3 +265,93 @@ def test_save_heatmap_content_is_valid_png(tmp_path): adapter.save_node(node) written = (adapter.nodes_dir / "0.500000_1.heatmap.png").read_bytes() assert written == original_png + + +def test_no_write_lineage_suppresses_node_files_and_lineage(tmp_path, dummy_node): + """Regression: --no-write-lineage was documented as suppressing lineage.csv + and the per-node files, but the flag never reached storage at all. + """ + adapter = FileStorageAdapter(str(tmp_path / "out.svg"), write_lineage=False) + adapter.initialize() + adapter.save_node(dummy_node) + + assert adapter.lineage_csv is not None + assert not adapter.lineage_csv.exists() + assert adapter.nodes_dir is not None + assert list(adapter.nodes_dir.iterdir()) == [] + + +def test_write_lineage_true_still_writes(tmp_path, dummy_node): + adapter = FileStorageAdapter(str(tmp_path / "out.svg"), write_lineage=True) + adapter.initialize() + adapter.save_node(dummy_node) + + assert adapter.lineage_csv is not None + assert adapter.lineage_csv.exists() + assert adapter.nodes_dir is not None + assert [p.name for p in adapter.nodes_dir.iterdir()] == ["0.123456_42.svg"] + + +def test_extensionless_output_does_not_collide_with_the_project_dir( + tmp_path, dummy_node +): + """Regression: project_dir was parent/stem, which for an extensionless path + *is* the output path. initialize() created it as a directory and save_best + could then never write the file -- a completed search produced no output and + no error, because the write failure was suppressed. + """ + output = tmp_path / "myout" + adapter = FileStorageAdapter(str(output)) + adapter.initialize() + + assert adapter.project_dir != output + adapter.save_best(dummy_node) + assert output.is_file() + assert output.read_text(encoding="utf-8") == "" + + +def test_runs_started_in_the_same_second_get_distinct_directories(tmp_path): + """Regression: the run dir name is second-resolution and was created with + exist_ok=True, so concurrent runs shared one directory and interleaved + appends into a single stats.csv and lineage.csv. + """ + dirs = set() + for _ in range(5): + adapter = FileStorageAdapter(str(tmp_path / "out.svg")) + adapter.initialize() + dirs.add(adapter.current_run_dir) + assert len(dirs) == 5 + + +def test_resume_parses_inf_scored_node_files(tmp_path): + """save_node writes f"{score:.6f}", which renders INVALID_SCORE as a bare + "inf", so the filename grammar has to accept it. + """ + nodes = tmp_path / "out" / "runs" / "2026-01-01_00-00-00" / "nodes" + nodes.mkdir(parents=True) + (nodes / "0.200000_2.svg").write_text("", encoding="utf-8") + (nodes / "inf_7.svg").write_text("", encoding="utf-8") + + adapter = FileStorageAdapter(str(tmp_path / "out.svg"), resume=True) + resumed = adapter.load_resume_nodes() + + assert {node_id for node_id, _ in resumed} == {2, 7} + assert adapter.max_node_id == 7 + + +def test_resume_top_tolerates_a_non_numeric_filename(tmp_path): + """Regression: the --resume-top sort re-parsed the score out of the stem, so + any file whose prefix was not a float raised an unhandled ValueError. + """ + nodes = tmp_path / "out" / "runs" / "2026-01-01_00-00-00" / "nodes" + nodes.mkdir(parents=True) + (nodes / "0.100000_1.svg").write_text("", encoding="utf-8") + (nodes / "0.900000_2.svg").write_text("", encoding="utf-8") + (nodes / "handwritten.svg").write_text("", encoding="utf-8") + + adapter = FileStorageAdapter(str(tmp_path / "out.svg"), resume=True, resume_top=2) + resumed = adapter.load_resume_nodes() # must not raise + + # The best-scoring real node is kept; the unparseable one sorts last (inf). + assert 1 in {node_id for node_id, _ in resumed} + assert len(resumed) == 2 diff --git a/src/vectrify/tests/vector/test_worker.py b/src/vectrify/tests/vector/test_worker.py index 995c686..4ebe758 100644 --- a/src/vectrify/tests/vector/test_worker.py +++ b/src/vectrify/tests/vector/test_worker.py @@ -5,7 +5,7 @@ from vectrify.image_utils import png_bytes_to_data_url, resize_long_side from vectrify.tests.helpers import make_png as _make_png -from vectrify.vector.worker import _use_llm +from vectrify.vector.worker import _should_use_llm, _use_llm def test_use_llm_no_svg_uses_llm_when_rate_nonzero(): @@ -172,3 +172,46 @@ def test_worker_preview_preserves_small_image(): _, b64 = preview.split(",", 1) img = Image.open(io.BytesIO(base64.b64decode(b64))) assert img.size == (32, 32) + + +def test_should_use_llm_rate_zero_overrides_force_llm(): + """Regression: epoch-0 seed tasks set force_llm, which bypassed the rate + entirely, so --llm-rate 0 still issued LLM calls and offline runs were + impossible. + """ + assert ( + _should_use_llm( + force_llm=True, has_content=False, llm_rate=0.0, llm_pressure=1.0 + ) + is False + ) + assert ( + _should_use_llm( + force_llm=True, has_content=True, llm_rate=0.0, llm_pressure=1.0 + ) + is False + ) + + +def test_should_use_llm_honours_force_llm_when_enabled(): + assert ( + _should_use_llm( + force_llm=True, has_content=True, llm_rate=0.01, llm_pressure=0.0 + ) + is True + ) + + +def test_should_use_llm_without_content_needs_a_nonzero_rate(): + assert ( + _should_use_llm( + force_llm=False, has_content=False, llm_rate=1.0, llm_pressure=1.0 + ) + is True + ) + assert ( + _should_use_llm( + force_llm=False, has_content=False, llm_rate=0.0, llm_pressure=1.0 + ) + is False + ) diff --git a/src/vectrify/utils.py b/src/vectrify/utils.py index 2b09fd9..ac7f6dc 100644 --- a/src/vectrify/utils.py +++ b/src/vectrify/utils.py @@ -5,7 +5,20 @@ from pathlib import Path -def setup_logger(level: str, log_file: Path | str | None = None) -> None: +def setup_logger( + level: str, + log_file: Path | str | None = None, + console: bool = True, +) -> None: + """Configure root logging to stderr and, when given, to *log_file*. + + A log file is added alongside the console handler rather than replacing it: + this is called a second time once the run directory exists, and dropping + stderr there left piped or scripted runs with no output at all -- including + the "no valid candidate found" and "best candidate written" lines. Pass + ``console=False`` only when something else owns the terminal, i.e. the live + dashboard. + """ lvl = getattr(logging, level.upper(), logging.INFO) fmt = logging.Formatter( "%(asctime)s | %(processName)s | %(levelname)s | %(message)s", @@ -16,14 +29,15 @@ def setup_logger(level: str, log_file: Path | str | None = None) -> None: root.handlers.clear() root.setLevel(lvl) + if console: + sh = logging.StreamHandler(sys.stderr) + sh.setFormatter(fmt) + root.addHandler(sh) + if log_file is not None: fh = logging.FileHandler(str(log_file), mode="a", encoding="utf-8") fh.setFormatter(fmt) root.addHandler(fh) - else: - sh = logging.StreamHandler(sys.stderr) - sh.setFormatter(fmt) - root.addHandler(sh) def start_log_listener() -> tuple[mp.Queue, QueueListener]: diff --git a/src/vectrify/vector/runner.py b/src/vectrify/vector/runner.py index 501a691..03f8cd7 100644 --- a/src/vectrify/vector/runner.py +++ b/src/vectrify/vector/runner.py @@ -16,6 +16,7 @@ DEFAULT_EPOCH_DIVERSITY, DEFAULT_MAX_TOTAL_TASKS, DEFAULT_POOL_SIZE, + DEFAULT_TOURNAMENT_SIZE, _default_llm_rate, ) from vectrify.formats.models import VectorStatePayload @@ -146,6 +147,7 @@ def run_vector_search( beams: int = 10, cull_keep: float = 0.5, epoch_diversity: float = DEFAULT_EPOCH_DIVERSITY, + tournament_size: int = DEFAULT_TOURNAMENT_SIZE, epoch_variance: float | None = None, max_epochs: int | None = None, epoch_pool_size: int | None = None, @@ -166,7 +168,9 @@ def run_vector_search( storage.initialize() assert storage.current_run_dir is not None run_log_file = storage.current_run_dir / "search.log" - setup_logger(log_level, log_file=run_log_file) + # The dashboard owns the terminal, so console logging is suppressed only + # then; otherwise stderr keeps receiving records alongside the log file. + setup_logger(log_level, log_file=run_log_file, console=dashboard is None) log_queue, log_listener = start_log_listener() # Suppress tqdm / HF noise before any library imports or workers spawn. @@ -235,6 +239,18 @@ def _start_scorer_thread() -> None: ) initial_nodes = filter_to_pool_size(initial_nodes, pool_size, strategy_type) + # With the LLM disabled the search can only mutate existing candidates, so + # without at least one it would dispatch nothing and idle until the wall + # clock. Fail immediately with the reason instead. + if llm_rate <= 0 and not any( + n.state.payload.content for n in initial_nodes if n.state.payload + ): + raise ValueError( + "--llm-rate 0 disables all LLM calls, but there are no existing " + "candidates to mutate. Resume a previous run with --resume, or " + "allow LLM calls so the first candidate can be generated." + ) + if not initial_nodes: initial_nodes.append( SearchNode( @@ -272,6 +288,7 @@ def _start_scorer_thread() -> None: pool_size=pool_size, crossover_distance_threshold=10, epoch_diversity=epoch_diversity, + tournament_size=tournament_size, ) ) diff --git a/src/vectrify/vector/storage.py b/src/vectrify/vector/storage.py index 30d81bc..37a660b 100644 --- a/src/vectrify/vector/storage.py +++ b/src/vectrify/vector/storage.py @@ -37,6 +37,7 @@ def __init__( resume_top: int | None = None, save_raster: bool = False, save_heatmap: bool = False, + write_lineage: bool = True, ): self.output_path = Path(output_path) self.file_extension = file_extension @@ -44,10 +45,18 @@ def __init__( self.resume_top = resume_top self.save_raster = save_raster self.save_heatmap = save_heatmap + self.write_lineage = write_lineage self._max_id = 0 self.base_name = self.output_path.stem - self.project_dir = self.output_path.parent / self.base_name + # An extensionless output path has stem == filename, which would make + # project_dir the output path itself: initialize() creates it as a + # directory and save_best can then never write the file. Suffix the + # project dir in that case so the two can never collide. + project_name = self.base_name + if not self.output_path.suffix: + project_name = f"{self.base_name}_runs" + self.project_dir = self.output_path.parent / project_name self.runs_dir = self.project_dir / "runs" self.current_run_dir: Path | None = None @@ -60,13 +69,31 @@ def max_node_id(self) -> int: def initialize(self) -> None: self.runs_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - self.current_run_dir = self.runs_dir / timestamp + self.current_run_dir = self._claim_run_dir() self.nodes_dir = self.current_run_dir / "nodes" self.nodes_dir.mkdir(parents=True, exist_ok=True) self.lineage_csv = self.current_run_dir / "lineage.csv" log.debug(f"Storage initialized at: {self.current_run_dir}") + def _claim_run_dir(self) -> Path: + """Create and return a run directory no other run is using. + + The timestamp is second-resolution, so two runs started in the same + second would otherwise share a directory and interleave appends into one + stats.csv and lineage.csv. Suffixing on collision keeps the name + readable, unlike adding sub-second precision. + """ + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + for attempt in range(1, 100): + name = timestamp if attempt == 1 else f"{timestamp}-{attempt}" + candidate = self.runs_dir / name + try: + candidate.mkdir(parents=False, exist_ok=False) + return candidate + except FileExistsError: + continue + raise RuntimeError(f"Could not claim a run directory under {self.runs_dir}") + def load_resume_nodes(self) -> list[tuple[int, str]]: if not self.resume or not self.runs_dir.exists(): return [] @@ -94,23 +121,29 @@ def load_resume_nodes(self) -> list[tuple[int, str]]: log.info(f"Loading nodes to resume from latest run: {latest_run.name}") ext = re.escape(self.file_extension) - file_pattern = re.compile(rf"^([0-9.]+)_(\d+){ext}$") - parsed_files = [] + # `inf` is its own alternative because save_node writes f"{score:.6f}", + # which renders INVALID_SCORE as a bare "inf". + file_pattern = re.compile(rf"^(inf|[0-9.]+)_(\d+){ext}$") + parsed_files: list[tuple[int, Path, float]] = [] glob_pattern = f"*{self.file_extension}" for file_path in target_nodes_dir.glob(glob_pattern): match = file_pattern.match(file_path.name) node_id = int(match.group(2)) if match else self._max_id + 1 + # Score comes from the match, not from re-splitting the stem: a + # user-dropped file with a non-numeric prefix would otherwise raise + # an unhandled ValueError while sorting for --resume-top. + score = float(match.group(1)) if match else float("inf") self._max_id = max(self._max_id, node_id) - parsed_files.append((node_id, file_path)) + parsed_files.append((node_id, file_path, score)) if self.resume_top is not None: - parsed_files.sort(key=lambda x: float(x[1].stem.split("_")[0])) + parsed_files.sort(key=lambda item: item[2]) parsed_files = parsed_files[: self.resume_top] resumed_data = [] - for node_id, file_path in parsed_files: + for node_id, file_path, _score in parsed_files: try: content = file_path.read_text(encoding="utf-8").strip() if content: @@ -126,21 +159,19 @@ def save_node(self, node: SearchNode[VectorStatePayload]) -> None: self._max_id = max(self._max_id, node.id) + # --no-write-lineage suppresses the per-node files and lineage.csv, but + # the raster/heatmap sidecars stay under their own flags. + if not self.write_lineage: + self._save_sidecars(node) + return + base_fn = f"{node.score:.6f}_{node.id}" if node.state.payload.content: content_path = self.nodes_dir / f"{base_fn}{self.file_extension}" content_path.write_text(node.state.payload.content, encoding="utf-8") - if self.save_raster and node.state.payload.raster_data_url: - _, b64 = split_data_url(node.state.payload.raster_data_url) - png_path = self.nodes_dir / f"{base_fn}.png" - png_path.write_bytes(base64.b64decode(b64)) - - if self.save_heatmap and node.state.payload.heatmap_data_url: - _, b64 = split_data_url(node.state.payload.heatmap_data_url) - heatmap_path = self.nodes_dir / f"{base_fn}.heatmap.png" - heatmap_path.write_bytes(base64.b64decode(b64)) + self._save_sidecars(node) content_md5 = ( hashlib.md5(node.state.payload.content.encode()).hexdigest() @@ -161,6 +192,21 @@ def save_node(self, node: SearchNode[VectorStatePayload]) -> None: } ) + def _save_sidecars(self, node: SearchNode[VectorStatePayload]) -> None: + """Write the optional .png / .heatmap.png next to a node.""" + assert self.nodes_dir is not None + base_fn = f"{node.score:.6f}_{node.id}" + + if self.save_raster and node.state.payload.raster_data_url: + _, b64 = split_data_url(node.state.payload.raster_data_url) + (self.nodes_dir / f"{base_fn}.png").write_bytes(base64.b64decode(b64)) + + if self.save_heatmap and node.state.payload.heatmap_data_url: + _, b64 = split_data_url(node.state.payload.heatmap_data_url) + (self.nodes_dir / f"{base_fn}.heatmap.png").write_bytes( + base64.b64decode(b64) + ) + def _append_lineage_row(self, row: dict[str, object]) -> None: """Append one lineage row, writing the header first if the file is new. diff --git a/src/vectrify/vector/worker.py b/src/vectrify/vector/worker.py index f40c78b..1ff41bc 100644 --- a/src/vectrify/vector/worker.py +++ b/src/vectrify/vector/worker.py @@ -52,6 +52,20 @@ def _use_llm(has_content: bool, llm_rate: float, llm_pressure: float) -> bool: return not has_content or random.random() < llm_rate * llm_pressure +def _should_use_llm( + force_llm: bool, has_content: bool, llm_rate: float, llm_pressure: float +) -> bool: + """Whether this task calls the LLM. + + ``llm_rate <= 0`` is a hard off switch and overrides *force_llm*: epoch-0 + seed tasks set force_llm, so without this ``--llm-rate 0`` still issued LLM + calls and there was no way to run offline. + """ + if llm_rate <= 0: + return False + return force_llm or _use_llm(has_content, llm_rate, llm_pressure) + + def worker_loop(task_q: mp.Queue, result_q: mp.Queue, ctx: WorkerContext): signal.signal(signal.SIGINT, signal.SIG_IGN) setup_worker_logger(ctx.log_level, ctx.log_queue) @@ -80,8 +94,8 @@ def worker_loop(task_q: mp.Queue, result_q: mp.Queue, ctx: WorkerContext): parent = task.parent_state has_content = bool(parent.payload.content) - use_llm = task.force_llm or _use_llm( - has_content, ctx.llm_rate, task.llm_pressure + use_llm = _should_use_llm( + task.force_llm, has_content, ctx.llm_rate, task.llm_pressure ) llm_type = None