build-small-hackathon/microfactory-lab
2
1"""Headless tests for the deterministic core (no Ollama required).2 3Exercises retrieval, the Spine veto, the offline-fallback advisor, and the4reflection append. Run: `make test` (= `uv run python test_core.py`).5"""6 7from __future__ import annotations8 9import os10import tempfile11from pathlib import Path12 13# Force the deterministic fallback path so this suite is truly offline + fast14# even when `ollama serve` is running (otherwise advise() would do a real, slow15# model call and appear to hang). Set before importing modules that call the LLM.16os.environ.setdefault("CHIEF_ENGINEER_OFFLINE", "1")17 18from core.chief_engineer import advise19from core.ledger import LedgerManager20from core.models import Environment, Job, PrintSettings21from core.reflect import reflect_on_job22from core.seed_lessons import ensure_seeded23from core.spine import SpineValidator24 25 26def test_seed_and_retrieve():27 led = LedgerManager(Path(tempfile.mkdtemp()) / "lessons.jsonl")28 n = ensure_seeded(led)29 assert n == 12, f"expected 12 seeds, got {n}"30 # PLA overhang in a warm room → should match the warm-room sag seed nearest31 hits = led.retrieve("PLA", "overhang", temp=28, humidity=50)32 assert hits, "expected precedent for PLA/overhang"33 assert hits[0][0].geometry_type == "overhang" and hits[0][0].material == "PLA"34 # a material+geometry with no seeds → empty (valid 'no precedent' case)35 assert led.retrieve("TPU", "vase", 22, 45) == []36 print("✓ seed + retrieval (nearest:", hits[0][0].job_id, f"dist {hits[0][1]:.2f})")37 38 39def test_spine_veto():40 s = SpineValidator()41 # model proposes a PLA nozzle way too hot → must clamp to 220 and trip approval42 bad = PrintSettings(nozzle_temp=260, bed_temp=60, retraction_mm=5, fan_pct=100, first_layer_fan_pct=0)43 res = s.check(bad, "PLA")44 assert res.settings.nozzle_temp == 220, res.settings.nozzle_temp45 assert res.requires_approval and res.vetoes46 print("✓ spine clamps PLA 260→220 and trips HITL:", res.vetoes[0])47 48 49def test_fallback_advise():50 led = LedgerManager(Path(tempfile.mkdtemp()) / "lessons.jsonl")51 ensure_seeded(led)52 job = Job(geometry_type="overhang", material="PLA", description="45° bracket")53 env = Environment(temp=28, humidity=50)54 rec = advise(job, env, led.retrieve("PLA", "overhang", 28, 50))55 assert rec.used_fallback, "no Ollama here → should use fallback"56 assert rec.advice.settings.nozzle_temp > 0 and rec.advice.risks57 print("✓ fallback advise:", rec.advice.reasoning[:70], "…")58 59 60def test_reflect_appends():61 led = LedgerManager(Path(tempfile.mkdtemp()) / "lessons.jsonl")62 ensure_seeded(led)63 before = led.count()["earned"]64 job = Job(geometry_type="bridge", material="PETG")65 env = Environment(temp=24, humidity=44)66 settings = PrintSettings(nozzle_temp=235, bed_temp=80, retraction_mm=4, fan_pct=70, first_layer_fan_pct=0)67 entry = reflect_on_job(job, env, settings, "success", led)68 assert led.count()["earned"] == before + 1 and entry.source == "earned"69 print("✓ reflect appends earned lesson:", entry.lesson[:70], "…")70 71 72def test_retrieval_orders_by_env_distance():73 led = LedgerManager(Path(tempfile.mkdtemp()) / "lessons.jsonl")74 ensure_seeded(led)75 # PLA/stringing seeds sit at (22,45) and (24,70). A humid query should rank76 # the humid seed first; a dry query the dry one.77 humid = led.retrieve("PLA", "stringing", temp=24, humidity=70)78 dry = led.retrieve("PLA", "stringing", temp=22, humidity=45)79 assert humid[0][0].env_humidity >= 65, humid[0][0].env_humidity80 assert dry[0][0].env_humidity <= 50, dry[0][0].env_humidity81 print("✓ retrieval ranks by normalized env distance (humid→humid, dry→dry)")82 83 84def test_gcode_readout_ties_to_settings():85 from core.viewer import gcode_readout86 s = PrintSettings(nozzle_temp=205, bed_temp=60, retraction_mm=5, fan_pct=100, first_layer_fan_pct=0)87 g = gcode_readout(s, "PLA")88 assert "M104 S205" in g and "M140 S60" in g, g89 assert "layer height 0.20 mm" in g, g90 print("✓ g-code header is populated from proposed settings")91 92 93# GIF export button removed; keep motion preview tests via UI smoke if needed.94 95 96def test_virtual_printer_html_ties_to_settings():97 from core.widgets import virtual_printer_html98 from core.viewer import generate_primitive99 from core.models import PrintSettings100 mesh, _geo = generate_primitive("box", 20)101 default_html = virtual_printer_html(mesh)102 assert "0.20 mm layers" in default_html, default_html103 fine = PrintSettings(nozzle_temp=200, bed_temp=60, retraction_mm=4.5, fan_pct=80,104 first_layer_fan_pct=0, layer_height=0.12)105 fine_html = virtual_printer_html(mesh, settings=fine)106 assert "0.12 mm layers" in fine_html, fine_html107 print("✓ virtual-print preview layer height follows PrintSettings")108 109 110def test_ingest_distiller():111 from pathlib import Path as _P112 from ingest.distill import parse_prusa_ini, parse_klipper_cfg, parse_marlin_config113 samples = _P(__file__).resolve().parent / "ingest" / "samples"114 prusa = parse_prusa_ini(samples / "prusa_filaments.ini")115 assert any(f.material == "PLA" and f.param == "bed_temp" for f in prusa), "PLA bed_temp not parsed"116 assert parse_klipper_cfg(samples / "klipper_extruder.cfg"), "klipper max_temp not parsed"117 assert parse_marlin_config(samples / "marlin_config.h"), "marlin maxtemp not parsed"118 print("✓ distiller parses Prusa INI + Klipper cfg + Marlin config")119 120 121def test_precedent_eval_narration():122 from core.viewer import precedent_eval_html123 from core.models import LessonEntry as LE, Environment as E124 e = LE(job_id="x", material="PLA", geometry_type="overhang", env_temp=28, env_humidity=50,125 outcome="failed_sag", lesson="sagged", source="seed", timestamp="t")126 html = precedent_eval_html([(e, 0.28)], E(temp=32, humidity=62))127 assert "warmer" in html and "more humid" in html and "worse" in html, html128 assert "NO CLOSE PRECEDENT" in precedent_eval_html([], E(temp=22, humidity=45))129 print("✓ precedent evaluation narrates env delta + novel case")130 131 132def test_simulator_physical_and_deterministic():133 from sim.outcome import simulate134 from core.models import Job as J, Environment as E, PrintSettings as PS135 bad = PS(nozzle_temp=235, bed_temp=80, retraction_mm=4, fan_pct=40, first_layer_fan_pct=0)136 r1 = simulate(bad, J(geometry_type="bridge", material="PETG"), E(temp=29, humidity=62))137 assert r1.outcome != "success" and r1.quality < 0.7, r1138 r2 = simulate(bad, J(geometry_type="bridge", material="PETG"), E(temp=29, humidity=62))139 assert (r2.outcome, r2.quality) == (r1.outcome, r1.quality), "simulator must be deterministic"140 good = PS(nozzle_temp=205, bed_temp=60, retraction_mm=5, fan_pct=100, first_layer_fan_pct=0)141 rg = simulate(good, J(geometry_type="overhang", material="PLA"), E(temp=20, humidity=40))142 assert rg.outcome == "success", rg143 # build-plate position: corner > edge > center warp for a shrink-prone material;144 # 'center' (default) must be unchanged.145 abs_s = PS(nozzle_temp=248, bed_temp=95, retraction_mm=4, fan_pct=20, first_layer_fan_pct=0)146 env = E(temp=22, humidity=40)147 qc = simulate(abs_s, J(geometry_type="adhesion", material="ABS", bed_position="center"), env).quality148 qe = simulate(abs_s, J(geometry_type="adhesion", material="ABS", bed_position="edge"), env).quality149 qk = simulate(abs_s, J(geometry_type="adhesion", material="ABS", bed_position="corner"), env).quality150 assert qc > qe > qk, (qc, qe, qk)151 assert qc == simulate(abs_s, J(geometry_type="adhesion", material="ABS"), env).quality152 print("✓ simulator is physical + deterministic, and bed-position warps edges/corners")153 154 155def test_policy_learns_and_generalizes():156 import tempfile, os157 from pathlib import Path158 from learn.policy import LearnedPolicy159 from learn.loop import run_session, run_iteration160 from core.ledger import LedgerManager161 from core.models import Job as J, Environment as E162 d = Path(tempfile.mkdtemp())163 pol = LearnedPolicy(path=d / "policy.json")164 led = LedgerManager(path=d / "lessons.jsonl")165 job, env = J(geometry_type="bridge", material="PETG"), E(temp=29, humidity=62)166 sess = run_session(job, env, 10, pol, led)167 assert sess.trajectory[-1] > sess.trajectory[0], "quality must improve"168 assert sess.first_success is not None, "should reach a clean print"169 # generalization: a similar (same-bucket, different exact env) job benefits170 cold = sess.trajectory[0]171 warm_start = run_iteration(J(geometry_type="bridge", material="PETG"),172 E(temp=28, humidity=58), pol, led, 1, record=False)173 assert warm_start.result.quality > cold, "policy must transfer to similar conditions"174 print("✓ policy learns (quality climbs to a clean print) and generalizes to similar jobs")175 176 177if __name__ == "__main__":178 test_seed_and_retrieve()179 test_spine_veto()180 test_fallback_advise()181 test_reflect_appends()182 test_retrieval_orders_by_env_distance()183 test_gcode_readout_ties_to_settings()184 test_virtual_printer_html_ties_to_settings()185 test_ingest_distiller()186 test_precedent_eval_narration()187 test_simulator_physical_and_deterministic()188 test_policy_learns_and_generalizes()189 print("\nALL CORE TESTS PASSED")190 