diff --git a/.eamos-core/tools/emit_svg.py b/.eamos-core/tools/emit_svg.py index dab98cd..0711c9c 100644 --- a/.eamos-core/tools/emit_svg.py +++ b/.eamos-core/tools/emit_svg.py @@ -130,7 +130,22 @@ def _trunc(s, n): def build_mindmap_svg(ir): - """A deterministic horizontal mind map from a graph-IR: root → branches → leaves.""" + """A deterministic mind map from a graph-IR: root → branches → leaves. The `orientation` + param picks the layout (#63) — horizontal (root left, reading rows) or vertical (root top, + one column per branch). Both are pure functions of the branch order (RFC-0002 §11-1).""" + if ir.get("orientation") == "vertical": + return _mindmap_vertical(ir) + return _mindmap_horizontal(ir) + + +def _box(parts, x, y, w, text, fill, txt, size=14, bold=False): + parts.append(f'') + parts.append(f'{esc(text)}') + + +def _mindmap_horizontal(ir): bg, accent, body, amber, muted, card = THEMES["professional"] branches = ir.get("branches", []) row_h = 46 @@ -141,25 +156,19 @@ def build_mindmap_svg(ir): f''] root_y = H // 2 - def box(x, y, w, text, fill, txt, size=14, bold=False): - parts.append(f'') - parts.append(f'{esc(text)}') - def link(x1, y1, x2, y2): mx = (x1 + x2) // 2 parts.append(f'') - box(rx, root_y, 320, _trunc(ir.get("root", ""), 46), accent, "#FFFFFF", 15, True) + _box(parts, rx, root_y, 320, _trunc(ir.get("root", ""), 46), accent, "#FFFFFF", 15, True) y = 40 for b in branches: leaves = b.get("leaves", []) or [] span = max(1, len(leaves)) * row_h by = y + span // 2 link(rx + 320, root_y, bx, by) - box(bx, by, 300, _trunc(b.get("label", ""), 40), card, accent, 14, True) + _box(parts, bx, by, 300, _trunc(b.get("label", ""), 40), card, accent, 14, True) ly = y + row_h // 2 for leaf in leaves: link(bx + 300, by, lx, ly) @@ -172,6 +181,41 @@ def link(x1, y1, x2, y2): return "\n".join(parts) + "\n" +def _mindmap_vertical(ir): + """The vertical layout (#63): root on top, one column per branch, leaves stacked beneath. + Deterministic like its horizontal twin — a pure function of the branch order.""" + bg, accent, body, amber, muted, card = THEMES["professional"] + branches = ir.get("branches", []) + col_w, box_w, row_h, m = 320, 300, 30, 30 + n = max(1, len(branches)) + depth = max([len(b.get("leaves", []) or []) for b in branches] or [0]) + W = m * 2 + n * col_w - (col_w - box_w) + H = 180 + depth * row_h + 40 + parts = [f'', + f''] + + def vlink(x1, y1, x2, y2): + my = (y1 + y2) // 2 + parts.append(f'') + + root_cx = W // 2 + _box(parts, root_cx - 160, 57, 320, _trunc(ir.get("root", ""), 46), accent, "#FFFFFF", 15, True) + for i, b in enumerate(branches): + x = m + i * col_w + bcx = x + box_w // 2 + vlink(root_cx, 74, bcx, 123) + _box(parts, x, 140, box_w, _trunc(b.get("label", ""), 40), card, accent, 14, True) + ly = 180 + for leaf in b.get("leaves", []) or []: + col = amber if "⟨" in str(leaf) else body + parts.append(f'{esc(_trunc(leaf, 40))}') + ly += row_h + parts.append("") + return "\n".join(parts) + "\n" + + def build_topology_svg(ir): """A deterministic 'layered' system-topology diagram from a topology-IR (RFC-0004): typed nodes placed in a grid by DECLARATION ORDER (a pure function of the order — no force-directed, no diff --git a/.eamos-core/tools/render.py b/.eamos-core/tools/render.py index a2d203d..286a77d 100644 --- a/.eamos-core/tools/render.py +++ b/.eamos-core/tools/render.py @@ -537,19 +537,27 @@ def build_graph_ir(manifest, archetype, altitude=None, function=None): slide = _build_section(sec, content, ledger, acc, lang) leaves = [t for t in (_flatten_block(b) for b in slide["blocks"]) if t][:3] branches.append({"label": slide["title"], "leaves": leaves}) + + # The orientation param is live (#63): stamped into the IR, the SVG emitter lays out per value. + params = _resolve_params(load_deliverable("mindmap"), _find_deliverable(manifest, "mindmap")) + orientation = params.get("orientation") or "horizontal" review_appendix = [ {"binding": k, "value": "" if ledger[k].get("value") is None else str(ledger[k].get("value")), "assumption": ledger[k].get("assumption", ""), "fill_from": ledger[k].get("fill_from", "")} for k in sorted(acc["assumed"]) ] return {"ir_version": IR_VERSION, "generator": GENERATOR, - "deliverable": "mindmap", "output_lang": lang, "root": manifest.get("objective", ""), + "deliverable": "mindmap", "orientation": orientation, + "output_lang": lang, "root": manifest.get("objective", ""), "branches": branches, "review_appendix": review_appendix}, acc def build_quiz_ir(manifest, archetype, altitude=None, function=None): """Project an interview quiz (quiz-IR, RFC-0002 §3, §11-3): graded questions cite a ledger - source (required); discussion questions (from decisions) are un-scored. SAME ledger.""" + source (required); discussion questions (from decisions) are un-scored. SAME ledger. + The `count` param is live (#63): short caps the graded questions at 5, standard at 10, long is + uncapped — a deterministic cut over the SORTED ledger keys, so the subset is stable under + manifest reordering. Discussion questions are never capped.""" acc = _new_acc() ident = manifest.get("identity", {}) or {} ctx = manifest.get("context", {}) or {} @@ -558,10 +566,13 @@ def build_quiz_ir(manifest, archetype, altitude=None, function=None): lang = ctx.get("output_lang", "en") stem, disc = labels.lab(lang, "core", "quiz_stem"), labels.lab(lang, "core", "quiz_disc") + params = _resolve_params(load_deliverable("interview_quiz"), _find_deliverable(manifest, "interview_quiz")) + cap = {"short": 5, "standard": 10, "long": None}.get(params.get("count") or "standard", 10) + questions = [] - for key, cell in ledger.items(): - if not (key.startswith("kpi.") and isinstance(cell, dict)): - continue + graded_keys = sorted(k for k, c in ledger.items() if k.startswith("kpi.") and isinstance(c, dict)) + for key in graded_keys if cap is None else graded_keys[:cap]: + cell = ledger[key] assumed = _cell_assumed(key, cell, acc) # fail closed (#53) if assumed: acc["assumed"][key] = cell diff --git a/.eamos-core/tools/tests/test_render.py b/.eamos-core/tools/tests/test_render.py index 49ef5ca..9c5a04f 100644 --- a/.eamos-core/tools/tests/test_render.py +++ b/.eamos-core/tools/tests/test_render.py @@ -135,6 +135,53 @@ def test_every_projection_is_stamped(self): self.assertEqual(ir["generator"], "eamos-render") +class TestDeliverableParams(unittest.TestCase): + """The registry knobs must change the output — validated-but-dead is worse than undeclared (#63).""" + + def _quiz_graded(self, count): + m, arch = load("qbr-c-level") + for i in range(7): # 5 real kpi cells + 7 synthetic = 12 graded candidates + m["inputs"][f"kpi.zz{i}"] = {"label": f"Z{i}", "value": str(i), "provided": True, + "source": "x", "provenance": "sourced"} + next(d for d in m["deliverables"] if d["type"] == "interview_quiz")["count"] = count + quiz, _ = render.build_quiz_ir(m, arch) + return quiz["questions"] + + def test_quiz_count_caps_graded_questions(self): + for count, expected in (("short", 5), ("standard", 10), ("long", 12)): + with self.subTest(count=count): + graded = [q for q in self._quiz_graded(count) if q["kind"] == "graded"] + self.assertEqual(len(graded), expected) + + def test_quiz_count_never_caps_discussion(self): + qs = self._quiz_graded("short") + self.assertEqual(len([q for q in qs if q["kind"] == "discussion"]), 2) # both decisions kept + + def test_quiz_cap_is_stable_under_manifest_reordering(self): + a = [q["q"] for q in self._quiz_graded("short") if q["kind"] == "graded"] + m, arch = load("qbr-c-level") + m["inputs"] = dict(reversed(list(m["inputs"].items()))) # same cells, new order + for i in range(7): + m["inputs"][f"kpi.zz{i}"] = {"label": f"Z{i}", "value": str(i), "provided": True, + "source": "x", "provenance": "sourced"} + next(d for d in m["deliverables"] if d["type"] == "interview_quiz")["count"] = "short" + quiz, _ = render.build_quiz_ir(m, arch) + self.assertEqual([q["q"] for q in quiz["questions"] if q["kind"] == "graded"], a) + + def test_mindmap_orientation_changes_layout(self): + import emit_svg + m, arch = load("qbr-c-level") + mind, _ = render.build_graph_ir(m, arch) + self.assertEqual(mind["orientation"], "horizontal") # qbr requests horizontal + horiz = emit_svg.build_mindmap_svg(mind) + next(d for d in m["deliverables"] if d["type"] == "mindmap")["orientation"] = "vertical" + mind_v, _ = render.build_graph_ir(m, arch) + self.assertEqual(mind_v["orientation"], "vertical") + vert = emit_svg.build_mindmap_svg(mind_v) + self.assertNotEqual(horiz, vert) # the knob changes the output + self.assertEqual(vert, emit_svg.build_mindmap_svg(mind_v)) # and stays deterministic + + class TestDecisionContract(unittest.TestCase): def test_decision_contract_renders_through_md(self): import emit_md