Tree of choices — Canonical design record

Overview

The core idea is that a CV or cover letter is a recursive tree where each node holds a pool of candidate children and a selection (which to render and in what order). The same concept applies at every depth: section choice, item variants within a section, and bullet alternatives within an item. OPEN-tier nodes generate fresh text from a prompt instead of choosing from a pool. The whole document reduces to a single recursive primitive.

Node shape

interface Node {
  id: string;           // nanoid
  kind: string;         // 'section' | 'item' | 'bullet' | 'personal-info' | etc.
  tier: 'FIXED' | 'HYBRID' | 'OPEN';
  children: Node[];     // pool of candidates
  selection: {
    active: string[];   // ordered chosen subset of child ids to render
    minCount?: number;  // optional lower bound
    maxCount?: number;  // optional upper bound
  };
  data?: unknown;       // leaf content (FIXED) or seed (HYBRID)
  prompt?: string;      // generation instruction (OPEN nodes only)
  applicable_langs?: string[];  // rare — when a node applies only in specific languages
}

Every operation in the system is a transformation of this shape. Tier meanings: FIXED = verbatim copy, never modified by LLM; HYBRID = LLM selects best-fit from pool, may lightly rephrase; OPEN = LLM generates fresh text from prompt, must stay factually grounded in parent/data context.

selection.active[] is the single generalized choice primitive: choose n child IDs out of the m IDs in children[], preserving the chosen render order. The old one-out-of-many variant behavior is the special case where a parent has a variant pool and selection.maxCount = 1 (or minCount = maxCount = 1 when exactly one choice is required). Broader section, item, bullet, and sub-bullet selection use the same field with a larger cardinality.

Per-language peer trees with translate ops and render fallback

CareerVector stores CV/CL content in independent peer trees, one per language. There is no cross-language inheritance, no sparse-delta model, no English-as-base. Every language is its own complete Node tree.

Editing English does not touch German. Editing German does not touch English. They are peers. Translation is an explicit user-invoked operation, not an inheritance side-effect.

Layer Behavior
Within a language Recursive Node tree (sections → items → bullets) with children pool + selection.active[]. Tree-of-choices applies at every depth.
Across languages (default) Peer full trees. Each language is its own complete Node tree under cv_profile.<lang>.tree and cl_profile.<lang>.tree. Independent after creation. Edit English → German untouched. Delete in German → English untouched.
Adding a new language Full clone of source language tree + translate pass over translatable fields. One-time operation — no ongoing link.
Translate operation User-invoked: copy a field, section, or bullet from any source language to any target language, applying translation. Any direction (English→German, French→German, etc.). This is the explicit "translate" affordance in the editor.
Render fallback If a requested language tree does not exist, fall back to a configurable default (most users default to English; some may default to French, etc.). Runtime only — no copy is made, no tree is created.
Per-job tailoring Snapshot from the chosen language tree at tailor time. Independent thereafter.

There is no separate "workspace identity" layer and no English-as-L0 concept. Each language tree is complete and authoritative for that language.

Translate operations

Translation between languages is explicit and user-triggered. Translate operations can target a single field, a section, or an entire tree. Any source/target language pair is valid — translation is not English-centric.

The translate affordance lives in the editor as a per-field or per-section button. Invoking it copies the content from the source language tree, runs a translation pass, and writes the result into the target language tree. The two trees remain independent after the operation.

Broadcast mode (backlog)

Broadcast mode is a single editor-level toggle — same UI pattern as the v1 showWhitespace toggle (cvl/index.tsx:218, toolbar position). Not built during the v2 rewrite; backlog item only.

While toggled on, edits in the currently-active language tab replicate to all other language trees (with translation). Toggle off → edits stay in the active tab only. Session/UI state only, not persistent. Storage stays peer trees — toggle just routes writes to multiple trees instead of one.

Decision 1 — Per-language trees stay separate

The system stores profiles at cv_profile/<lang>/tree and cl_profile/<lang>/tree. Each language is its own independent peer tree. Reason: cultural restructuring needs differ significantly — German CVs often put education before experience; photo-header sections are taboo in US markets. An applicable_langs filter on a single monolithic tree was rejected as more code with worse semantics. A per-language tree handles "section X exists only in German" with zero filter logic. Note: the applicable_langs field remains on Node for rare cross-tree exceptions but is not the primary mechanism for language differences.

Decision 2 — Unified recursive concept

Sections, intra-section item variants, and bullet-level alternatives all collapse into one recursive concept. There are no "variant groups" as a separate system, no "groupState" as a side channel, no special parameters for personal info. Everything is a Node. The tree recurses top-down. At each level the question is the same: given this pool, which children should render and in what order?

Decision 3 — Pool + selection separation (Option A)

Three options were considered for representing selections:

Option A — Separate pool and selection (chosen)

children[] is the immutable pool. selection.active[] is the ordered list of IDs to render. These are independent stores. Reordering the pool is editor-internal state; reordering selection.active[] changes render output. Toggling a child moves its ID in or out of selection.active[].

Option B — Annotated list

One combined list with an active boolean per item plus an explicit order field. Simpler in storage, but the LLM contract is messy: "preserve list + flip flags + reorder" has more failure modes than subset selection.

Option C — Active list only, pool implicit

Store only the active list; infer the pool from the union of all per-job active lists. This is lossy: items that were never selected in any job become unrecoverable.

Why Option A wins:

  • Efficiency: Per-job overrides are tiny (~500B selection deltas vs ~5KB whole annotated lists under B/C).
  • LLM contract: "Given pool, pick subset + order" is a clean, well-defined function shape.
  • Caching: The static pool prefix is cacheable via Anthropic prompt caching; only the selection varies per call.
  • Scale: At million-CV scale, ~10x less D1 storage and ~25x fewer LLM output tokens compared to Option C.
  • Preservation: A subsumes C. You can represent C with A by setting selection.active[] to the full pool order. The reverse is lossy.
  • Design cost: The editor cost (two surfaces instead of one) is paid once in design; A's efficiency recurs on every tailoring operation.

WARNING: Mixing A and C in different parts of the system wrecks things — like endianness. If one subsystem stores the pool and another only stores the active list, there is no canonical source of truth for what exists. Pick one model and apply it everywhere. Option A is that model.

Decision 4 — Tailored CVs are full snapshots, not deltas

Entries at jobs/<id>/tailored_cv and tailored_cl are complete copies of the chosen language tree at tailor-time (children + selection + data). The tailored copy is a materialized snapshot from a single language tree — it is not a merge of English plus language overrides (no such merge exists; each language tree is already complete). The snapshot is flat (no pointers back to the quarry tree). Each application diverges independently after the fork. Quarry edits do not propagate to tailored copies — this is intentional to preserve historical application state. The re-tailor button wipes the branch and re-snapshots from the current language tree. Storage cost is negligible (~500B per job selection override). Yjs CRDT semantics apply naturally; concurrent edits to a tailored copy from multiple cooperators converge.

Decision 5 — Pipeline is async, job-creation-triggered

The tailoring pipeline runs automatically when a job is added (by the user or by a bot/automation). It is multi-stage, multi-LLM (an agent crew): ingests user profile, preferences, quarry state, job posting, and company research, then makes selections from the quarry and generates OPEN-tier text. By the time the user opens the editor for a job, the tailored output already exists. The editor is the post-generation review surface, not a generator. Status is surfaced via LEDs / greyed-out dropdown (in-progress) / editor popping into view when done. Per-stage gating (commute gate needs flat+work; CV gate needs profile) lives in the pipeline state machine, not in the tree shape.

Decision 6 — Human-in-the-loop edits

When the AI gets it wrong — which is often — the user proofreads and corrects. Three edit modes:

  1. Selection edits — checkboxes toggle selection.active[] membership. Fast, non-destructive.
  2. Content edits — inline text changes to node.data. Local to the tailored fork; does not affect the quarry.
  3. Promotion — new in v2, missing in v1. A "Save as variant" button promotes the local edit back to the quarry tree as a new candidate in the Node's pool. The original is always preserved. Cleanup is explicit deletion via on the variant. This is the mechanism for improving the quarry based on what worked in real applications.

Decision 7 — variant groups are siblings, not a side channel

Under tree-of-choices, "variant groups" are not a separate concept. Sections that compete are siblings under the same parent node. The JOIN/LEAVE operations are node.move(from_path, to_path). There is no separate groupState map — it is just selection.active[] on the parent. Atomicity is free (one Yjs transaction). The only invariant the broker validates: parent.selection.active[] ⊆ parent.children IDs.

Top-level section variants are wrapped in variant-pool Nodes whose own selection.active[] chooses the rendered member; the parent's selection.active[] decides whether the wrapper renders at all. The cvl.duplicate_as_variant and cvl.break_out action ops handle wrapper creation and dissolution atomically.

Decision 8 — PersonalInfo placement

PersonalInfo collapses into the tree as a Node with kind: 'personal-info', tier: 'FIXED', and data: {name, email, phone, ...}, placed in the root tree like any other node. No special storage slot, no side-channel. This satisfies PRINCIPLES.md #11 ("Everything is a Node") at the tree level.

Because each language is a peer tree, PersonalInfo lives as a full node within each language tree independently. The German tree's PersonalInfo node holds city: "Frankfurt" directly; the Korean tree's node holds the localized name directly. These are not deltas of an English node — they are independent full nodes in independent trees. Per-job differences (e.g., city: "Berlin" for one specific position) live in the tailored snapshot of that language tree's node as full values.

Decision 9 — Op surface

The tree-of-choices model requires a revised op catalog. These ops replace the section-only ops from v1. All node.* and selection.update ops take a branch_path argument (e.g. cv_profile.de.tree, cl_profile.fr.tree, jobs/<id>/tailored_cv). The path determines which language tree or tailored snapshot the op targets. There is no merge logic — every op applies to exactly one tree.

Op Path target Description
node.create(parentPath, node) quarry language tree Add a child node to the pool at parentPath
node.update(path, fields) any tree Patch data, prompt, kind, applicable_langs
node.delete(path) quarry language tree Remove a node; must update parent.selection.active[] in same transaction
node.move(from_path, to_path) quarry language tree Move a node between parents (replaces transferVariantGroup)
selection.update(path, delta) any tree Modify selection — add/remove IDs, set active order, set minCount/maxCount. The primary user gesture.
tailored.snapshot(jobId, branch_path) creates tailored copy Fork a language tree into jobs/<id>/tailored_cv or tailored_cl
tailored.promote(path) tailored copy → quarry language tree Send a local edit back to the quarry. Always adds as a new variant in the pool — never replaces the existing node. No mode arg.

The branch_path root segment names which profile and language (cv_profile.de, cl_profile.fr, etc.); the op does not care which language. selection.update is the most frequent op. The delta shape: { add?: string[], remove?: string[], setActive?: string[], minCount?: number | null, maxCount?: number | null }. Partial updates; omitted fields are not touched. null clears a bound.

Warning — Endian analogy

Option A (separate pool + selection) and Option C (active-only) are like big-endian and little-endian. Each is internally consistent. Mixing them — pool here, active-only there — produces corruption that is hard to detect and impossible to merge cleanly. Every part of the system must use Option A. There is no valid subset where C is acceptable because it seems simpler.

Relationship to existing principles

This design directly satisfies several core principles from PRINCIPLES.md:

  • #2 (State machine semantics): node.create/delete/move are explicit, traceable transitions.
  • #3 (No fallbacks): Invalid selection.active[] entries are rejected, not silently ignored.
  • #6 (Multi-cooperator first): Yjs CRDT over pool+selection supports concurrent edits natively.
  • #7 (Agent API parity): The op surface is identical for the human GUI and MCP.
  • #11 (Everything is a Node): Every document component is a Node in the recursive tree.
  • #17 (CVL unification): The Node tree shape is identical for CV and CL. No discriminator on the tree model or op surface.
  • #18 (No identity inference): All ops require an explicit branch_path. No implicit "current language" or "current tree."

Note: this design superseded the predecessor's variantGroup/groupState system. PRINCIPLES.md #14 (Chesterton's fence) does not protect that legacy machinery — it has been replaced by selection.active[] and the variant-pool wrapper.

Open questions

  1. Promotion mode: RESOLVED 2026-04-26 — always variant, no mode arg. See Decision 9 above and OPEN-QUESTIONS.md.
  2. applicable_langs filter: Is a node-level language filter ever actually needed, or does the per-language tree make it dead code? (See OQ-2)
  3. OPEN-tier ground truth: How does the system detect LLM hallucination in OPEN-tier nodes? Out of scope for tree design; belongs in pipeline spec. (See OQ-3)

Full details in OPEN-QUESTIONS.md.

Migration history

The tree-of-choices model is the canonical shape today. Production data has been migrated through several supervised passes:

  1. Flat sections: Y.Array<Y.Map> + groupState: Y.Map → recursive Node tree with children[] + selection.active[].
  2. Top-level section variants → variant-pool wrapper Nodes (post-Option B wedge).
  3. Tailored DocumentStack snapshots → full Node-tree snapshots.

The canonical op contract for these shapes is CLAUDE.md §17 and §24.

Source: wiki/content/architecture/TREE-OF-CHOICES.md