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'")
+ 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