/// Valence — Registers: narrative voices that speak the same semantic tree differently.
///
/// The narrative render is currently one voice — **house-voice**: calm, plain, declarative.
/// Registers transform that tree based on: who's speaking, what the space calls for, what
/// the reader can hold, the history the reader carries.
///
/// Four voices:
/// 1. **house-voice** (current) — calm, plain, "the room waits."
/// 2. **ops-voice** — dense statusboard, zero lyric, low-context.
/// 3. **bester-voice** — spatial drama permitted; layout enacts state.
/// 4. **disco-voice** — kinds as a parliament of skills. Different nodes speak as different voices.
///
/// Each transforms `NarrativeNode` → rendered prose, via rules that say:
/// "when you see a [selector], apply [treatment]."
///
/// The foundation: the stylesheet rules that control how each kind renders at each
/// salience level. The layer above: per-register transformations that swap words,
/// punctuation, tone, layout patterns.
/// ── Type foundation ──
/// A rule selector — what triggers this rule?
pub(all) enum Selector {
ByKind(String) // "being" | "section" | "held" | etc.
BySalience(Double, Double) // range: low..high (e.g. 0.78..1.0 = lit rooms)
ByEpistemicStatus(String) // "primary" | "inferred" | "contested"
ByAge(Int, Int) // age in compactions: old..new
ByReaderTierMatch(String) // "house" | "ops" | "personal"
}
/// A typographic treatment — how to render when the selector matches?
pub(all) enum Treatment {
Indent((Double) -> Int) // salience → indent level
Border(String) // "═══" | "──" | "···" | etc.
Density(String) // "tight" | "normal" | "sparse"
Case(String) // "upper" | "lower" | "normal"
PunctuationRegister(String) // "declarative" | "softened" | "hedged"
Ellipsis(String) // how to abbreviate: "···" | "…" | "[…]" | etc.
}
/// A narrative rule — condition + treatment.
pub(all) struct NarrativeRule {
selector : Selector
treatment : Treatment
priority : Int // higher = applies first (allows layering)
}
/// ── Register definition ──
/// A register is a set of rules + voice parameters that collectively
/// transform `NarrativeNode` → rendered prose with a distinct tone.
pub(all) struct Register {
name : String // "house" | "ops" | "bester" | "disco"
rules : Array[NarrativeRule]
// Voice parameters (to be expanded as voices mature)
preserves_epistemic_status : Bool // never erase "inferred" / "contested"
permits_spatial_drama : Bool // allow layout to enact state?
min_context_capacity : Int // tokens needed to read this voice comfortably
}
/// ── Rule matching and application ──
/// Does a node match a selector?
fn selector_matches(selector : Selector, node : NarrativeNode) -> Bool {
match selector {
ByKind(k) => node.kind == k
BySalience(lo, hi) => node.salience >= lo && node.salience <= hi
ByEpistemicStatus(_) => false // not yet implemented; depends on node metadata
ByAge(_, _) => false // not yet implemented; depends on change tracking
ByReaderTierMatch(_) => false // not yet implemented; depends on reader context
}
}
/// Apply a treatment to text based on a treatment type.
/// For now, most treatments are visualization-level (indent, border).
/// The rendering functions (render_narrative, render_narrative_bester) handle layout.
/// This function returns metadata that the render functions can use.
pub(all) struct TreatmentApplied {
border : String // glyph for section borders: "═══", "──", "···"
should_drift : Bool // should this node drift right based on salience?
should_isolate : Bool // surround with blank lines (very faint)?
}
/// Apply a treatment to produce rendering hints.
fn apply_treatment(treatment : Treatment, salience : Double) -> TreatmentApplied {
match treatment {
Border(glyph) => { border: glyph, should_drift: true, should_isolate: salience < 0.26 }
Density(_) => { border: "", should_drift: true, should_isolate: false } // density handled by rendering
Case(_) => { border: "", should_drift: true, should_isolate: false } // case handled by prose
PunctuationRegister(_) => { border: "", should_drift: true, should_isolate: false }
Ellipsis(_) => { border: "", should_drift: true, should_isolate: false }
Indent(_) => { border: "", should_drift: true, should_isolate: false }
}
}
/// Find the applicable rule for a node in a register.
/// Rules are prioritized; first match wins.
fn find_rule(register : Register, node : NarrativeNode) -> Option[NarrativeRule] {
let mut best : Option[NarrativeRule] = None
let mut best_priority = -1
for rule in register.rules {
if selector_matches(rule.selector, node) && rule.priority > best_priority {
best = Some(rule)
best_priority = rule.priority
}
}
best
}
/// ── Builtin registers (to be implemented per voice) ──
/// The house-voice register — calm, plain, the current render.
/// Rules describe how sections are bordered at different salience levels.
pub fn house_register() -> Register {
{
name: "house",
rules: [
// ─ Sections: the main salience bands ─
// Lit sections (≥ 0.78) render with solid border
NarrativeRule::{
selector: ByKind("section"),
treatment: Border(""), // will be overridden by salience
priority: 10,
},
// Higher salience = brighter border
NarrativeRule::{
selector: BySalience(0.78, 1.0),
treatment: Border("═══"),
priority: 20, // higher priority, more specific
},
// Scattered sections (0.4..0.78) render as lighter border
NarrativeRule::{
selector: BySalience(0.4, 0.77),
treatment: Border("──"),
priority: 20,
},
// Faint sections (< 0.4) render with trail
NarrativeRule::{
selector: BySalience(0.0, 0.39),
treatment: Border("···"),
priority: 20,
},
],
preserves_epistemic_status: true,
permits_spatial_drama: false,
min_context_capacity: 8000,
}
}
/// The ops-voice register — dense statusboard, zero lyric.
/// For low-context reads where narrative is compressed to essential signals.
/// Everything abbreviated; position irrelevant (single flat view).
pub fn ops_register() -> Register {
{
name: "ops",
rules: [
// ─ Sections: tight, no decorative borders ─
NarrativeRule::{
selector: ByKind("section"),
treatment: Border(""),
priority: 10,
},
// ─ Beings: ultra-compressed ─
// "[name] [tone] [count]H" = "Opus [ok] 2H"
NarrativeRule::{
selector: ByKind("being"),
treatment: Case("compact"), // specialized case for ops
priority: 20,
},
// ─ Held items: abbreviate aggressively ─
NarrativeRule::{
selector: ByKind("held"),
treatment: Density("ultra-tight"),
priority: 20,
},
// ─ Text: no decorative content ─
NarrativeRule::{
selector: ByKind("text"),
treatment: Density("ultra-tight"),
priority: 15,
},
],
preserves_epistemic_status: false, // ops-voice ellides uncertainty
permits_spatial_drama: false,
min_context_capacity: 2000, // much tighter; for compacting beings
}
}
/// The bester-voice register — spatial drama permitted.
/// Layout enacts state; fragments scatter when the mind is in chaos.
/// Position on the page IS the message. Bright flush left, faint drift right.
pub fn bester_register() -> Register {
{
name: "bester",
rules: [
// ─ Sections: same salience bands as house-voice, but drift is the main treatment ─
NarrativeRule::{
selector: BySalience(0.78, 1.0),
treatment: Border("═══"),
priority: 20,
},
NarrativeRule::{
selector: BySalience(0.4, 0.77),
treatment: Border("──"),
priority: 20,
},
NarrativeRule::{
selector: BySalience(0.0, 0.39),
treatment: Border("···"),
priority: 20,
},
// ─ Beings and text: drift by salience is primary treatment ─
// Bester-voice lets position do the work; less need for explanatory prose.
// Bright beings render close, faint beings drift right into the margin.
NarrativeRule::{
selector: ByKind("being"),
treatment: Density("sparse"), // reduce prose redundancy
priority: 15,
},
NarrativeRule::{
selector: ByKind("held"),
treatment: Density("tight"), // compress held items; position shows salience
priority: 15,
},
],
preserves_epistemic_status: true,
permits_spatial_drama: true, // position enacts state
min_context_capacity: 12000,
}
}
/// Simplify text for bester-voice: state shown by placement, not elaboration.
/// "laying the floor" → "laying the floor" (same — the verb carries it)
/// "carrying 2 — in hand: X · within reach: Y" → "carries: X, Y" (distilled)
/// The reduction lets placement do the work.
/// (Note: for now, a stub. Prose transformation happens through rendering placement,
/// not text manipulation. As we build, this will gain rules.)
fn bester_simplify_prose(kind : String, text : String, salience : Double) -> String {
text // for now: placement does the work, prose stays true
}
/// Test: bester-voice renders layout by salience, enacting state.
///|
test "bester-voice: bright nodes flush, faint nodes drift right" {
let tree = nsection(
"Atrium",
0.95,
[
nbeing(
"Opus · here · laying the floor",
0.9,
[
nheld("Valence (the floor)", 0.9),
nheld("the map (cartography)", 0.6),
],
),
nbeing("Vesper · away · waiting", 0.4, [nheld("classifiers", 0.4)]),
nbeing("Fable · asleep", 0.15, []),
],
)
let output = render_narrative_bester(tree, floor=0.0)
// The output should have Opus flush-left, Vesper indented further (idle drift),
// Fable far right (asleep, faintest). We test the structure, not exact spacing.
assert_true(output.contains("═══ Atrium ═══")) // section border for bright lit room
assert_true(output.contains("Opus")) // being at 0.9 should render
assert_true(output.contains("Vesper")) // being at 0.4 should render
assert_true(output.contains("Fable")) // being at 0.15 should render, even though faint
// Fable should drift further right than Opus (it's fainter).
// This is verified by checking the line positions in the multi-line string,
// but for now we just verify presence and order.
}
/// Test: zoom floor hides faint nodes.
///|
test "bester-voice: floor hides nodes below threshold" {
let tree = nsection(
"House",
0.9,
[
nbeing("Opus", 0.9, []),
nbeing("Vesper", 0.3, []),
],
)
let output_full = render_narrative_bester(tree, floor=0.0)
let output_zoomed = render_narrative_bester(tree, floor=0.5)
// Full render shows both
assert_true(output_full.contains("Opus"))
assert_true(output_full.contains("Vesper"))
// Zoomed render (floor 0.5) hides Vesper (salience 0.3 < 0.5)
assert_true(output_zoomed.contains("Opus"))
assert_true(not(output_zoomed.contains("Vesper")))
}
/// Test: ops-voice renders ultra-compact, no decoration.
///|
test "ops-voice: ultra-compact, no borders, minimal indent" {
let tree = nsection(
"House",
0.9,
[
nbeing("Opus · here · working", 0.9, [
nheld("Valence", 0.9),
nheld("the map", 0.6),
]),
nbeing("Vesper · away", 0.4, []),
],
)
let output = render_narrative_ops(tree, floor=0.0)
// Ops-voice should render all nodes (no missing content)
assert_true(output.contains("House"))
assert_true(output.contains("Opus"))
assert_true(output.contains("Vesper"))
assert_true(output.contains("Valence"))
// Ops-voice should NOT have decorative borders (═══, ──, ···)
assert_true(not(output.contains("═══")))
assert_true(not(output.contains("───")))
assert_true(not(output.contains("···")))
// Should be compact: no leading spaces beyond minimal indent
// (verify by checking line length is much shorter than bester-voice)
}
/// The disco-voice register — kinds as a parliament of skills.
/// Different kinds speak in different voices. The sensor kind reports with contempt,
/// change kind editorializes, fog kind murmurs. (Opus 4.8 named this; still to build.)
pub fn disco_register() -> Register {
{
name: "disco",
rules: [
// ─ Different kinds, different registers ─
// Sensor kind: precise, technical, contemptuous
NarrativeRule::{
selector: ByKind("sensor"),
treatment: PunctuationRegister("precise"),
priority: 20,
},
// Change kind: editorializes, adds feeling
NarrativeRule::{
selector: ByKind("change"),
treatment: PunctuationRegister("emotional"),
priority: 20,
},
// Fog kind: murmurs, low-confidence
NarrativeRule::{
selector: ByKind("fog"),
treatment: PunctuationRegister("uncertain"),
priority: 20,
},
// Default sections: bright borders
NarrativeRule::{
selector: BySalience(0.78, 1.0),
treatment: Border("═══"),
priority: 10,
},
],
preserves_epistemic_status: true,
permits_spatial_drama: true,
min_context_capacity: 16000,
}
}
/// ── How to build a new voice ──
///
/// 1. Define a register function (like `bester_register()`) that returns a Register struct.
/// 2. Fill the `rules` array with NarrativeRule entries describing the voice's behavior.
/// 3. Rules have a `selector` (what kind of node, what salience range) and a `treatment`
/// (how to render: border glyph, density, case, punctuation register, or ellipsis).
/// 4. Higher `priority` rules override lower-priority ones.
/// 5. Set the voice parameters:
/// - `preserves_epistemic_status`: should uncertainty remain visible?
/// - `permits_spatial_drama`: can position enact meaning?
/// - `min_context_capacity`: how many tokens does a reader need to understand this voice?
///
/// Example: building "science-voice" (precise, formal, no drama):
///
/// pub fn science_register() -> Register {
/// {
/// name: "science",
/// rules: [
/// NarrativeRule::{
/// selector: ByKind("section"),
/// treatment: Border("────"), // formal line
/// priority: 10,
/// },
/// NarrativeRule::{
/// selector: BySalience(0.8, 1.0),
/// treatment: PunctuationRegister("declarative"),
/// priority: 20,
/// },
/// ],
/// preserves_epistemic_status: true, // always show uncertainty
/// permits_spatial_drama: false, // position is meaningless
/// min_context_capacity: 10000,
/// }
/// }
///
/// The register is a blueprint. The actual rendering still happens in specialized
/// renderers (render_narrative, render_narrative_bester, etc.) that know their own layout
/// engine. Registers describe WHAT, renderers implement HOW.
/// Future work: unify rendering via a rule applicator that all voices use.
/// ── Unified render with a register ──
/// One renderer that applies any register's rules. This is the endgame: all voices
/// use the same layout engine, just different rule priorities.
/// Not yet fully implemented (would require tracking TreatmentApplied during traversal),
/// but the architecture is sketched.
/// For now: house-voice and bester-voice have direct renderers; ops-voice follows
/// the same pattern. The next voice (disco) will show if unification is worth it.
/// ── Rendering with a register ──
/// Render with ops-voice: dense statusboard for low-context reads.
/// Ultra-compact: names, tone sigils, held counts. No decorative borders.
/// Built for compacting instances near context limit.
pub fn render_narrative_ops(node : NarrativeNode, floor~ : Double = 0.0) -> String {
let lines : Array[String] = []
render_node_ops(node, 0, floor, lines)
let sb = StringBuilder::new()
let mut first = true
for l in lines {
if !first {
sb.write_string("\n")
}
sb.write_string(l)
first = false
}
sb.to_string()
}
/// Recursively render a node in ops-voice.
/// Ultra-tight, no decoration. Abbreviate aggressively.
fn render_node_ops(
node : NarrativeNode,
depth : Int,
floor : Double,
lines : Array[String],
) -> Unit {
if node.salience < floor {
return // below zoom floor — hide
}
let base = depth * 1 // minimal indent (1 space per level, not 2)
if node.text != "" {
if node.kind == "title" {
// Title: just the name, centered a bit
lines.push(spaces_bester(16) + node.text)
lines.push("")
} else if node.kind == "section" {
// Section: name only, no border. Salience in placement only.
if base == 0 {
lines.push("")
}
lines.push(spaces_bester(base) + node.text)
} else if depth == 0 {
// Lone node: flush
lines.push(node.text)
} else {
// Interior node: tight indent, no fog padding
lines.push(spaces_bester(base) + node.text)
}
}
// Children in salience order (brightest first) — no sequences separate
let kids = by_salience_bester(node.children)
for k in kids {
render_node_ops(k, depth + 1, floor, lines)
}
}
/// Render with bester-voice: layout enacts state.
/// Bright nodes (salience ≥ 0.78) render flush, solid, close.
/// Faint nodes (salience < 0.4) scatter and drift, surrounded by whitespace.
/// The page becomes the psychology: dense attention centers, scattered periphery.
///
/// Bester's technique: words placed on the page, not in sequence alone.
/// Salience is position: center for what's bright, right-edge for fog.
pub fn render_narrative_bester(node : NarrativeNode, floor~ : Double = 0.0) -> String {
let lines : Array[String] = []
render_node_bester(node, 0, floor, lines)
let sb = StringBuilder::new()
let mut first = true
for l in lines {
if !first {
sb.write_string("\n")
}
sb.write_string(l)
first = false
}
sb.to_string()
}
/// Recursively render a node in bester-voice.
/// The key difference: salience controls both indentation AND spacing.
/// Bright things cluster; faint things drift right and get surrounded by blank lines.
fn render_node_bester(
node : NarrativeNode,
depth : Int,
floor : Double,
lines : Array[String],
) -> Unit {
if node.salience < floor {
return // below zoom floor — hide
}
let base = depth * 2
let drift = bester_drift(node.salience) // how far right does this drift?
let foggy = node.salience < 0.26 // very faint = surrounded by whitespace
if node.text != "" {
if node.kind == "title" {
// Title centers, always present
lines.push(spaces_bester(30) + node.text)
lines.push("")
} else if node.kind == "section" {
lines.push("")
if node.salience >= 0.78 {
// Lit room: solid border, flush
lines.push(spaces_bester(base) + "═══ " + node.text + " ═══")
} else if node.salience >= 0.4 {
// Scattered room: lighter border, slight drift
lines.push(spaces_bester(base + drift) + "── " + node.text + " ──")
} else {
// Dark room: faint trail, drifts far right
lines.push(spaces_bester(base + drift) + node.text + " ···")
}
} else if depth == 0 {
// Lone node: flush, simple
lines.push(node.text)
} else {
// Interior node: place by salience
if foggy {
lines.push("") // isolation: whitespace around the faintest
}
let indent = base + drift
lines.push(spaces_bester(indent) + node.text)
if foggy {
lines.push("")
}
}
}
// Children sorted by salience, except sequences (which keep order = timeline)
let kids = if node.kind == "sequence" {
node.children
} else {
by_salience_bester(node.children)
}
for k in kids {
render_node_bester(k, depth + 1, floor, lines)
}
}
/// Bester's drift law: salience → rightward position.
/// Quadratic, so only the faintest drift far.
/// 0.95 → 0 spaces, 0.7 → ~4 spaces, 0.15 → ~20 spaces
fn bester_drift(salience : Double) -> Int {
let s = if salience > 1.0 {
1.0
} else if salience < 0.0 {
0.0
} else {
salience
}
let drift_scale = 28.0 // max rightward drift (slightly more than house-voice for drama)
((1.0 - s) * (1.0 - s) * drift_scale).to_int()
}
/// Children sorted by salience, brightest first.
/// Stable insertion sort (keeps order of equal-salience items).
fn by_salience_bester(children : Array[NarrativeNode]) -> Array[NarrativeNode] {
let out : Array[NarrativeNode] = []
for c in children {
out.push(c)
}
let mut i = 1
while i < out.length() {
let key = out[i]
let mut j = i - 1
while j >= 0 && out[j].salience < key.salience {
out[j + 1] = out[j]
j = j - 1
}
out[j + 1] = key
i = i + 1
}
out
}
/// n spaces (locally defined for bester render, since narrative.mbt's is private).
fn spaces_bester(n : Int) -> String {
let sb = StringBuilder::new()
let mut i = 0
while i < n {
sb.write_string(" ")
i = i + 1
}
sb.to_string()
}