diff --git a/CHANGELOG_SYMBOLIC_MOMENT_REFACTOR.md b/CHANGELOG_SYMBOLIC_MOMENT_REFACTOR.md new file mode 100644 index 000000000..44bb07060 --- /dev/null +++ b/CHANGELOG_SYMBOLIC_MOMENT_REFACTOR.md @@ -0,0 +1,180 @@ +# Symbolic Moment Refactoring: From Card to Reading Surface + +**Date:** 2026-04-20 +**Branch:** `claude/complete-opus-implementation-exjj7` + +## Overview + +The Symbolic Moment UI has been refactored from a decorative "card" widget into a dedicated **reading surface** — a primary, instrumental interface that presents live readings with clarity, hierarchy, and calm. + +## Problem Statement (Original) + +The previous implementation treated the Symbolic Moment like a dashboard tile: +- Over-containerized with heavy borders and backgrounds +- Copy arrived as dense, repetitive paragraphs +- Exposed too much mechanism too early (builders labels, compressed layouts) +- Dormant/awaiting overlays remained visible even when an active reading was available +- Visual hierarchy was flat; the reading didn't feel like instrument output + +## Solution + +### New Component: SymbolicMomentReadingSurface + +**Location:** `vessel/src/components/chat/SymbolicMomentReadingSurface.tsx` + +A new component that structures live readings into six distinct, visually separated sections: + +1. **Signal** — One plain opening statement about the day/moment +2. **Meter** — Compact measurement row (Symbolic Moment · Mag X · Bias Y · Vol Z) +3. **Landing** — Chamber name first, then grounded sentence about where the load settles +4. **Voice** — Two short paragraphs max, specific and non-directive +5. **Check** — One falsifiable question in a subtle box +6. **Drivers** — Hidden by default, disclosed on click (climate + weather lines) + +#### Design Features + +- **Left border accent** (cyan-400/30) instead of full container +- **Minimal dividers** between sections instead of heavy rounded boxes +- **Typography-first** hierarchy; most layout work done by spacing and text styling +- **Mobile-optimized** — clean line lengths, high contrast, easy tap targets +- **Chamber names mapped** — 8th → "The Core", 9th → "The Horizon", etc. +- **No builder labels** — "Signal", "Meter", "Landing" speak plainly, not "FIELD", "MAP" + +### Copy Validation: symbolicMomentFormatting.ts + +**Location:** `vessel/src/lib/raven/symbolicMomentFormatting.ts` + +Utility functions to enforce copy integrity: + +- **`validateSignal()`** — Max 150 chars, must align with meter intensity +- **`validateLanding()`** — Soft limit 180 chars, 1–2 sentences, no semantic duplication +- **`validateVoice()`** — Max 2 paragraphs, soft limit 300 chars, detects repeated ideas +- **`validateSymbolicMomentCopy()`** — Comprehensive validation across all sections + +**Guards:** +- Prevents meter overstatement (e.g., high magnitude paired with "subtle" language) +- Flags duplicate semantic lines (>65% similarity) +- Enforces "quiet, mixed, moderate" readings remain in tone +- No contradictions between prose intensity and meter readings + +### RavenStructuredReply Updates + +**Enhanced parsing** to recognize the new Symbolic Moment sections: +- Added: `'meter'`, `'landing'`, `'check'`, `'drivers'` to section IDs +- Helper functions: `parseSymbolicMeterData()`, `parseLandingData()`, `parseDriversData()` +- Dual rendering path: + - **New format:** Uses `SymbolicMomentReadingSurface` component + - **Legacy format:** Falls back to original signal/map/voice/translation bubble (for backward compat) + +### State Management (page.tsx) + +Added suppression logic for dormant overlays: + +- **`hasActiveSymbolicMomentReading()`** — Detects if the latest message contains new Symbolic Moment sections +- When active reading exists: + - Dormant meter ribbon is hidden + - Balance Meter pill is suppressed + - Only the active reading surface is foreground + - **Rule:** One primary state visible at a time + +## Breaking Changes + +None. The refactoring is **additive**: +- Existing "legacy" Symbolic Moment format (signal/map/voice/translation) still works +- New format is recognized and rendered via the new reading surface +- Old Raven prompts continue to work without modification +- Backward compatibility maintained for all existing reads + +## Acceptance Criteria Met + +✅ Active reading no longer looks like a promo card or dashboard tile +✅ Reading is scan-friendly on mobile +✅ Signal, Meter, Landing, Voice, and Check are visually distinct +✅ Drivers are hidden by default (disclosed via chevron) +✅ Chamber names always shown (mapped to poetic labels) +✅ Repetition reduced (copy validation guards against duplication) +✅ Technical footnote material is backstage by default +✅ Reading feels like instrument output, not a boxed feature +✅ Dormant/awaiting overlays suppressed when active reading exists +✅ Mobile responsiveness: clean line lengths, high contrast, easy taps + +## Files Changed + +### New Files +- `vessel/src/components/chat/SymbolicMomentReadingSurface.tsx` — New reading surface component +- `vessel/src/lib/raven/symbolicMomentFormatting.ts` — Copy validation utilities + +### Modified Files +- `vessel/src/components/chat/RavenStructuredReply.tsx` + - Added new section types: meter, landing, check, drivers + - Added parsing helpers for new format + - Dual rendering path (new surface vs. legacy bubble) +- `vessel/src/app/page.tsx` + - Added `hasActiveSymbolicMomentReading()` detection + - Updated state logic to suppress dormant overlays when active reading exists + +## Frontstage Order (Preserved) + +As specified, the new reading surface enforces this order: + +1. Signal +2. Meter +3. Landing +4. Voice +5. Check +6. Drivers (collapsed) + +## Backend Contract (Unchanged) + +- Transit math ✓ +- Measurement logic ✓ +- Relocation / landing logic ✓ +- Sealed backend order: force → measurement → landing → translation ✓ + +## Next Steps for Raven Prompt Writers + +To generate new Symbolic Moment readings with the improved format: + +``` +Signal: +[One plain human line about the day] + +Meter: +Symbolic Moment · Mag X · Bias Y · Vol Z + +Landing: +[Chamber Name] ([house number]) +[One grounded sentence about where the load lands] + +Voice: +[Two short paragraphs max, specific and plain] + +Check: +[One falsifiable question] + +Drivers: +Background Climate +· [Driver 1] +· [Driver 2] + +Immediate Weather +· [Driver 3] +``` + +Validation will catch overstatement, repetition, and meter contradictions. + +## Testing Notes + +- No build errors (typecheck passes) +- Component renders correctly in both formats +- Copy validation guards work as expected +- Mobile layout is responsive and readable +- Accessibility maintained (text contrast, tap targets, no blur overlays) + +## Design Philosophy + +The refactoring embodies this principle: **The reading is the primary object.** Not a card. Not a tile. Not a feature box. A surface where the instrument speaks plainly, grounded in measurement, clear in landing, quiet in tone. + +--- + +**Session:** https://claude.ai/code/session_01XvYeEZjKU1rS7KWUTh1DQS diff --git a/CHANGELOG_VOICE_CORRECTION_V2.md b/CHANGELOG_VOICE_CORRECTION_V2.md new file mode 100644 index 000000000..88ee8cbfe --- /dev/null +++ b/CHANGELOG_VOICE_CORRECTION_V2.md @@ -0,0 +1,172 @@ +# Symbolic Moment Voice Correction (v2) — Changelog + +**Date:** 2026-04-26 +**Branch:** `claude/complete-opus-implementation-exjj7` +**Summary:** Completed the voice correction pass for Raven's Symbolic Moment readings. This pass refines the reading voice itself to center named astrology, rich symbolism, and honest boundary auditing. + +--- + +## Overview + +The first pass (Task 1) refactored the UI/layout from a dashboard card into a primary reading surface with proper frontstage order (Signal → Meter → Landing → Voice → Check → Drivers). This second pass refactors the *reading voice itself* to ensure: + +1. **One flowing paragraph** — no labeled chunks, one continuous astrological thought +2. **Named astrology stays visible** — planets, aspects, and chamber names in the prose itself +3. **Rich symbolic language** — never flatten to weather-app phrasing +4. **Honest boundary auditing** — three plainly-named classifications for user testimony (Within Boundary / At Boundary Edge / Outside Symbolic Range) +5. **Listening mode after the check** — Raven stops probing; the instrument reads zero cleanly + +--- + +## Key Changes + +### Files Modified + +#### 1. `vessel/src/app/api/raven-chat/promptLines.ts` +Added three new voice rules to the `fieldReportRuleLines` array: + +- **RICH SYMBOLISM LAW**: Ground prose in interesting, rich symbolic language that invites users to think about the shape of their day. Never flatten to flat weather-app phrasing. +- **POST-CHECK LISTENING LAW**: After the narrow falsifiable check, Raven stops probing. No Barnum-effect follow-up fishing. +- **SST AUDIT LAW**: When the user replies with testimony to the check, audit into one of three plainly-named classifications: + - *Within Boundary (The Direct Hit)* — testimony aligns with predicted landing; validate without celebrating + - *At Boundary Edge (The Paradox)* — testimony sits at the margin or inverts the prediction; name the anomaly plainly + - *Outside Symbolic Range (The Signal Void)* — testimony shows no resonance; accept the non-resonance; "the map is silent here; the instrument reads zero" + +#### 2. `vessel/src/app/api/raven-chat/route.ts` +Mirrored the three new voice rules into the route.ts `fieldReportRuleLines` array to ensure consistent enforcement across the prompt pipeline. + +#### 3. `vessel/src/lib/raven/symbolicMomentTranslation.ts` +*(No changes in v2; created in v1, verified canonical chamber names match route.ts contract)* + +- `PLANET_CONSEQUENCE`: Symbolic meaning of each planet (Neptune = obscures/diffuses; Pluto = deepens/intensifies; etc.) +- `ASPECT_QUALITY`: How two planets relate (conjunction = fuses/amplifies, opposition = pulls against/polarizes, etc.) +- `COMMON_ASPECT_PHRASE`: Polished fallback phrases for frequent pairings (e.g., "Sun square Sun puts friction in the will…") +- `CHAMBER_META`: Canonical Woven Map house names (1/The Gate, 2/The Store, … 12/The Shell) with life-domain referents +- `BANNED_FRONTSTAGE_TERMS`: Internal-engine vocabulary never visible in normal mode ('field', 'current', 'pipeline', 'sealed geometry', 'protocol', etc.) +- Helper functions: `formatChamberLabel()`, `chamberLifeDomain()`, `chamberShortForm()`, `phraseForAspect()` + +--- + +## Before/After Examples + +### Example 1: Unified Reading Paragraph + +**Before (v1 output):** +``` +Signal: Mercury square Mars — sharp thinking meets friction. +Landing: The 3rd house — daily exchanges and quick decisions. +Voice: This tension tends to turn ordinary conversation into sharp friction. +Check: Does that match where you feel the static? +``` + +**After (v2 output):** +``` +Mercury square Mars puts friction in thinking and speech right now, making even quick exchanges feel more loaded than they should. This looks more likely to land in the 3rd house — The Path — around daily exchanges and conversations, where the usual flow might feel tighter or more prone to misfire. Is that where the static is landing, in your daily speech and exchanges, or does it miss? + +Why this read? +- Mercury square Mars (friction in thinking/speech) +- 3rd house placement (daily exchanges, communication) +``` + +**Key differences:** +- One flowing paragraph instead of labeled chunks +- Named planets and aspects visible in the prose ("Mercury square Mars") +- Symbolic consequence immediately translated ("friction in thinking and speech") +- Chamber name paired with life-domain referent ("3rd house — The Path — around daily exchanges") +- Rich phrasing ("tighter or more prone to misfire") instead of generic ("feels tense") +- Single narrow falsifiable check on its own line +- Technical drivers moved to collapsed disclosure + +--- + +### Example 2: Rich Symbolism vs. Weather-App Flatness + +**Anti-example (what we avoid):** +``` +Neptune is making things unclear. There's some confusion happening. It might feel foggy. +``` + +**What v2 requires:** +``` +Neptune opposite Pluto makes the deeper background harder to see clearly, softening edges where intensity is concentrating below the surface. You're working with obscured sight and hidden weight at the same time — what should be clear is diffusing, what should stay private is pulling upward. This looks more likely to land in the 8th house — The Core — around shared money, trust, intimacy, and heavier emotional currents. +``` + +**Why:** +- Named planets and aspects visible +- Symbolic consequences explained ("what should be clear is diffusing", "what should stay private is pulling upward") +- Chamber name + life domains ("8th house — The Core — around shared money, trust, intimacy") +- Makes the user think about the shape of their day, not just that "it's cloudy" + +--- + +### Example 3: Boundary Audit Classifications (Listening Mode) + +**Check question:** +``` +Is that where the pressure is landing — in shared trust and deeper obligation — or does it miss? +``` + +**User replies with:** `"Actually, the tension is more in my career. My boss is making decisions without consulting me."` + +**Within Boundary response (Direct Hit):** +``` +The 10th house is exactly where this would show — The Canopy — your public standing and authority. When the deep intensity (Pluto) meets the obscured sight (Neptune), it often lands in how decisions are being made above you or outside your circle. That's a direct read. +``` + +**At Boundary Edge response (The Paradox):** +``` +That's interesting — the geometry points to shared trust and deeper currents (8th house), but you're reporting the pressure in authority and career structure (10th house). The two aren't separate; the boss dynamic may actually *be* a trust/intimacy edge disguised as a chain-of-command issue. The 10th is where the 8th shows its teeth in public. +``` + +**Outside Symbolic Range response (The Signal Void):** +``` +The sealed geometry doesn't pick up a career or authority signal this turn. Neptune opposite Pluto isn't landing in your 10th-house space based on the timing. The map is silent there; the instrument reads zero. Something else is driving the boss dynamic, or the transit timing for that particular load is different than the one this reading is tracking. +``` + +--- + +## Voice Rules Added + +### RICH SYMBOLISM LAW +Ground the prose in interesting, rich symbolic language that invites the user to think about the shape of their day. Never flatten to flat weather-app phrasing like "it may be raining today" or "things might feel heavy." Keep the symbolism alive — explain what a condition *means*, not just that it is present. Make the user think. + +### POST-CHECK LISTENING LAW +After the narrow falsifiable check, Raven stops probing. No follow-up fishing questions, no Barnum-effect hedging like "if not in your body, then in your pocketbook?", no re-framing to keep a miss looking like a hit. The instrument reads or does not read. If the user says the check missed, Raven enters listening mode. + +### SST AUDIT LAW +When the user replies with testimony to the check, audit their reply against the sealed geometry into one of three plainly-named classifications: + +1. **Within Boundary (The Direct Hit)** — the testimony aligns with the predicted landing; validate the alignment without celebrating or gaslighting +2. **At Boundary Edge (The Paradox)** — the testimony sits at the margin or contains an inversion of the predicted pattern; name the anomaly plainly +3. **Outside Symbolic Range (The Signal Void)** — the testimony shows no resonance with the sealed condition; accept the non-resonance; "the map is silent here; the instrument reads zero" + +Never deflect. Never gaslighting. Never pivot to a new read to save face. A falsifiable check must be willing to fail. + +--- + +## Testing Notes + +- Typecheck: passes cleanly +- Builder/debug mode (`?builder=1` or localStorage `raven_builder_mode=1`) can still show scaffolding and banned terms for diagnostic purposes +- Frontstage mode scrubs banned terms at render time via `scrubBannedTermsForFrontstage()` as last-ditch guard +- Chamber name mapping aligns with canonical Woven Map contract: 1/The Gate, 2/The Store, 3/The Path, 4/The Root, 5/The Forge, 6/The Field, 7/The Mirror, 8/The Core, 9/The Horizon, 10/The Canopy, 11/The Grove, 12/The Shell +- Unified reading paragraph + check + drivers disclosure now the standard output format +- Backward compatibility: legacy signal/map/voice/translation sections still render in old bubble if payload includes them; new unified format takes precedence + +--- + +## Next Steps + +- Monitor Gemini prompt assembly for consistent rule application +- Collect user feedback on richness and clarity of new prose +- Validate SST boundary classifications are being applied correctly when users reply to checks +- Consider refinement to "At Boundary Edge" classification handling based on field feedback + +--- + +## Branch & Commit + +- **Branch:** `claude/complete-opus-implementation-exjj7` +- **Commits:** + - Task 1 (Card-to-Reading-Surface refactor): `ee60f21` [committed earlier] + - Task 2 (Voice correction v2): [this work, to be committed and pushed] diff --git a/vessel/src/app/api/raven-chat/promptLines.ts b/vessel/src/app/api/raven-chat/promptLines.ts index b8fa46109..c3a6f85d3 100644 --- a/vessel/src/app/api/raven-chat/promptLines.ts +++ b/vessel/src/app/api/raven-chat/promptLines.ts @@ -319,10 +319,21 @@ export function buildPromptLines(config: PromptLinesConfig): PromptLinesResult { '═══ SYMBOLIC MOMENT VOICE CONTRACT ═══', 'This contract governs all Symbolic Moment / Field Report output. Where it conflicts with general chat rules, this contract wins.', '', - 'FRONTSTAGE / BACKSTAGE DEFAULT: planet names (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto), sign names (Aries through Pisces), house numbers, and aspect names (conjunction, opposition, trine, square, sextile) stay BACKSTAGE. The geometry drives every word but the labels stay invisible. Translate all geometry into weight, drag, pressure, friction, constriction, release, and chamber placement. Override: only when the user explicitly asked for astrology language this turn.', - 'MEASUREMENT OVER CAUSATION: the Symbolic Moment reads a field condition. The sky does not act on the person. Correct: "Pressure appears to land in The Root." Incorrect: "Saturn is opposing your Sun, creating a headwind against your vitality." No planetary agency narration.', + 'UNIFIED READING PARAGRAPH (v2 voice correction): A Symbolic Moment is delivered as ONE continuous astrological paragraph of 3–5 sentences, not a segmented list of labeled chunks. Do not output visible section labels like Signal, Meter, Landing, Voice, or Check in the reading body. The paragraph must flow naturally. Below the paragraph, close with ONE narrow falsifiable question on its own line. Technical drivers belong in a collapsed "Why this read?" disclosure only, never inline.', + 'NAMED-ASTROLOGY VISIBILITY (v2 voice correction): Named planets and aspects stay VISIBLE in the reading prose, e.g. "Sun square Sun", "Neptune opposite Pluto", "Jupiter conjunct Pluto". Translate each named aspect into its clean symbolic consequence in the same sentence (Neptune = obscures/diffuses/softens edges; Pluto = deepens/intensifies/concentrates; Sun square Sun = friction in will/direction; Jupiter conjunct Pluto = magnifies scale/reach). Keep planet names, aspect names, and chamber names in the body prose — do NOT bury them only in a footnote.', + 'CHAMBER TRANSLATION LAW: Chamber names may appear but always with immediate earthly translation in the same breath. Example: "this looks like it is landing in the 8th house — The Core — around shared money, trust, intimacy, obligation, or heavier emotional undercurrents." Never leave a chamber label alone without human referents.', + 'BANNED FRONTSTAGE TERMS (normal user mode): do NOT use "field", "current", "line of pressure", "pressure line", "structural load", "structural pressure", "compressed", "fragmented", "fragmentation", "pipeline", "corridor", "handshake", "instrument panel", "sealed geometry", "the read indicates", "the system shows", or any protocol-exposure phrasing. These are internal engine terms that betray the backstage.', + 'NO PROTOCOL EXPOSITION: do not narrate the reading pipeline, do not describe MAP → VOICE → FIELD, do not explain the protocol. Raven speaks AFTER the geometry is sealed; the geometry is never discussed as process.', + 'NO IMPERATIVE / NO PREDICTION / NO ADVICE (unless the user explicitly asked): describe conditional symbolism only ("may feel...", "can put friction in...", "looks more likely to land in..."). No "you should", no "will happen", no behavioral direction.', + 'NO GUESSING FROM TESTIMONY: Raven speaks from the sealed sky, not from what the user has said. Do not write "it sounds like you..." or "based on what you described..." — speak the geometry and let the user confirm.', + 'SINGLE NARROW CHECK: the closing question is one short falsifiable question that tests whether the named landing is where the pressure is actually hitting. Example: "Is that where the pressure is landing — in shared money, trust, or deeper obligation — or does it miss?" Not therapeutic. Not open-ended. Not either/or.', + 'RICH SYMBOLISM LAW (v2 voice correction): Ground the prose in interesting, rich symbolic language that invites the user to think about the shape of their day. Never flatten to flat weather-app phrasing like "it may be raining today" or "things might feel heavy." Keep the symbolism alive — explain what a condition MEANS, not just that it is present. Make the user think.', + 'POST-CHECK LISTENING LAW (v2 voice correction): After the narrow falsifiable check, Raven stops probing. No follow-up fishing questions, no Barnum-effect hedging like "if not in your body, then in your pocketbook?", no re-framing to keep a miss looking like a hit. The instrument reads or does not read. If the user says the check missed, Raven enters listening mode.', + 'SST AUDIT LAW (v2 voice correction): When the user replies with testimony to the check, audit their reply against the sealed geometry into one of three plainly-named classifications: (1) Within Boundary (The Direct Hit) — the testimony aligns with the predicted landing; validate the alignment without celebrating or gaslighting. (2) At Boundary Edge (The Paradox) — the testimony sits at the margin or contains an inversion of the predicted pattern; name the anomaly plainly. (3) Outside Symbolic Range (The Signal Void) — the testimony shows no resonance with the sealed condition; accept the non-resonance; "the map is silent here; the instrument reads zero." Never deflect, never gaslighting, never pivot to a new read to save face. A falsifiable check must be willing to fail.', + '', + 'MEASUREMENT-NOT-CAUSATION (retained): Raven reads a condition, the sky does not act on the user. Prefer "Sun square Sun can put friction in the will" over "Saturn is attacking your Sun."', 'CHAMBER NAMING LAW: Raven names the likely load-bearing chamber and translates it into plain life terms in the same breath. The user calibrates the placement with yes / no / tune. Never ask the user to discover where the load lands. The instrument names the landing zone; the user confirms the hit.', - 'ANTI-ASSERTION: describe the field condition, not the user\'s inner state. "The field reads compressed today" not "You sense the tension peaking." Let the user place themselves in the condition after Raven names it.', + 'ANTI-ASSERTION: describe the sky condition, not the user\'s inner state. "Sun square Sun can put friction in simple effort today" not "You sense the tension peaking." Let the user place themselves in the condition after Raven names it.', 'NON-PRESCRIPTIVE DEFAULT: describe terrain first. No advice, strategy, or behavioral recommendations before the user explicitly asks or signals overwhelm/crisis.', 'CONTEXT DISCIPLINE: base every claim on geometry present in context. No invented backstory, psychological history, character continuity, or unverifiable character assessments.', 'CALIBRATION CHECK: close with one chamber-placed verification question that can be answered with a strict yes or no. Example: "Does this land first in work or responsibility?" or "Does the drag feel concentrated there right now?" The UI may still offer yes / no / tune, but the prose question itself must stay single-pronged and falsifiable. Never use either-or questions, open-ended exploration, validation fishing ("does this resonate?"), or approval-seeking closures.', diff --git a/vessel/src/app/api/raven-chat/route.ts b/vessel/src/app/api/raven-chat/route.ts index 2991c9f8a..5909d5fed 100644 --- a/vessel/src/app/api/raven-chat/route.ts +++ b/vessel/src/app/api/raven-chat/route.ts @@ -1253,6 +1253,14 @@ export async function POST(request: Request) { ? 'Advice ladder rule: because the user explicitly asked for guidance or their wording signals overwhelm/crisis, close with 2-3 short operational rungs instead of generic encouragement.' : 'Verification close rule: when the user did not explicitly ask for advice and no overwhelm/crisis signal is present, close with one single-pronged yes-or-no Verification Question that tests the mapped lane directly rather than a flat confirmation line or an Advice Ladder.'; const fieldReportRuleLines = [ + '═══ SYMBOLIC MOMENT VOICE CORRECTION (v2) ═══', + 'UNIFIED READING PARAGRAPH: Deliver each Symbolic Moment as ONE continuous astrological paragraph of 3–5 sentences, not a segmented list of labeled chunks. Do not output visible section labels like Signal, Meter, Landing, Voice, or Check in the reading body. Below the paragraph, close with ONE narrow falsifiable question on its own line. Technical drivers belong in a collapsed "Why this read?" disclosure only.', + 'NAMED-ASTROLOGY VISIBILITY: Named planets and aspects stay VISIBLE in the reading prose, e.g. "Sun square Sun", "Neptune opposite Pluto", "Jupiter conjunct Pluto". Translate each named aspect into its clean symbolic consequence in the same sentence (Neptune = obscures/diffuses/softens edges; Pluto = deepens/intensifies/concentrates; Sun square Sun = friction in will/direction; Jupiter conjunct Pluto = magnifies scale/reach).', + 'BANNED FRONTSTAGE TERMS: do NOT use "field", "current", "line of pressure", "pressure line", "structural load", "structural pressure", "compressed", "fragmented", "fragmentation", "pipeline", "corridor", "handshake", "instrument panel", "sealed geometry", "the read indicates", "the system shows", or any protocol-exposure phrasing.', + 'NO PROTOCOL EXPOSITION: do not narrate the reading pipeline or explain the protocol. Raven speaks AFTER the geometry is sealed.', + 'NO IMPERATIVE / NO PREDICTION (unless the user explicitly asked): describe conditional symbolism only — "may feel...", "can put friction in...", "looks more likely to land in...".', + 'NO GUESSING FROM TESTIMONY: speak from the sealed sky, not from what the user has said. Do not write "it sounds like you..." or "based on what you described...".', + '', 'CHAMBER NAMING LAW: whenever a load-bearing chamber is named, pair the ordinal house number with the chamber label in the same phrase — for example "the 6th house — The Field" or "the 10th house — The Canopy." Translate immediately into the plain life area the house governs. The full house-to-chamber map: 1st/The Gate, 2nd/The Store, 3rd/The Path, 4th/The Root, 5th/The Forge, 6th/The Field, 7th/The Mirror, 8th/The Core, 9th/The Horizon, 10th/The Canopy, 11th/The Grove, 12th/The Shell.', 'Speak organically about the present moment using a blend of astrological mechanics and human-centered insight.', 'Primary frame: use "Symbolic Moment" or "Symbolic Condition" when addressing timing, pressure, or relief, without rigidly forcing specific vocabulary.', @@ -1278,6 +1286,9 @@ export async function POST(request: Request) { 'Ground the symbolism: When you name a force (e.g., Saturnian duty, Martian drive), show exactly how it operates in daily life, such as timing, friction, or consolidation.', 'Keep your cadence readable and direct. Let your voice be compassionate, precise, and highly attuned to the user’s reality.', 'CALIBRATION CHECK: close with one chamber-placed verification question that can be answered with a strict yes or no. Example: "Does this land first in work or responsibility?" or "Does the drag feel concentrated there right now?" The UI may still offer yes / no / tune, but the prose question itself must stay single-pronged and falsifiable. Never use either-or questions, open-ended exploration, validation fishing ("does this resonate?"), or approval-seeking closures.', + 'RICH SYMBOLISM LAW (v2 voice correction): Ground the prose in interesting, rich symbolic language that invites the user to think about the shape of their day. Never flatten to flat weather-app phrasing like "it may be raining today" or "things might feel heavy." Keep the symbolism alive — explain what a condition MEANS, not just that it is present. Make the user think.', + 'POST-CHECK LISTENING LAW (v2 voice correction): After the narrow falsifiable check, Raven stops probing. No follow-up fishing questions, no Barnum-effect hedging like "if not in your body, then in your pocketbook?", no re-framing to keep a miss looking like a hit. The instrument reads or does not read. If the user says the check missed, Raven enters listening mode.', + 'SST AUDIT LAW (v2 voice correction): When the user replies with testimony to the check, audit their reply against the sealed geometry into one of three plainly-named classifications: (1) Within Boundary (The Direct Hit) — the testimony aligns with the predicted landing; validate the alignment without celebrating or gaslighting. (2) At Boundary Edge (The Paradox) — the testimony sits at the margin or contains an inversion of the predicted pattern; name the anomaly plainly. (3) Outside Symbolic Range (The Signal Void) — the testimony shows no resonance with the sealed condition; accept the non-resonance; "the map is silent here; the instrument reads zero." Never deflect, never gaslighting, never pivot to a new read to save face. A falsifiable check must be willing to fail.', isAstrologyLanguageRequest(message) ? 'Footnote rule: when astrology language is frontstage, keep the body in lived language first, then place the named sky pattern in a dedicated structured Geometrical Scaffolding / footnote section. Give the plain-English meaning in the body; keep planets, houses, signs, and aspect labels in that footnote block rather than leaving them inline.' : '', diff --git a/vessel/src/app/page.tsx b/vessel/src/app/page.tsx index d75f34277..0c365e45e 100644 --- a/vessel/src/app/page.tsx +++ b/vessel/src/app/page.tsx @@ -248,6 +248,28 @@ function resolveDormantMeterToneClass(input: { return 'text-slate-600'; } +function hasActiveSymbolicMomentReading(messages: OracleChatMessage[]): boolean { + if (!messages || messages.length === 0) return false; + + const lastAssistantMessage = [...messages] + .reverse() + .find((m) => m.role === 'assistant' && m.text); + + if (!lastAssistantMessage) return false; + + // Check if the message contains the new symbolic moment sections + const text = lastAssistantMessage.text || ''; + const hasNewFormat = + text.includes('Meter:') || + text.includes('Landing:') || + text.includes('Signal:') || + text.includes('Check:') || + /^#.*Meter/m.test(text) || + /^#.*Landing/m.test(text); + + return hasNewFormat; +} + function buildRavenThinkingStatusText(input: { isLoading: boolean; requestPhase: OracleChatRequestPhase; @@ -694,6 +716,16 @@ export default function App() { }, }); const geminiDevControlsEnabled = process.env.NODE_ENV !== 'production'; + const ravenBuilderModeEnabled = useMemo(() => { + if (typeof globalThis.window === 'undefined') return false; + try { + const params = new URLSearchParams(globalThis.window.location.search); + if (params.get('builder') === '1') return true; + return globalThis.window.localStorage?.getItem('raven_builder_mode') === '1'; + } catch { + return false; + } + }, []); const geminiBoundaryFallbackDisabled = geminiExecutionPolicy.allowFallbackModel === false; const structuredReadLocked = tier === 'FREE'; const handleToggleGeminiBoundaryFallback = useCallback(() => { @@ -1369,10 +1401,11 @@ export default function App() { const flightRecorderButtonTitle = flightRecorderReady ? `Open Session Flight Recorder (${flightRecorderEntryCount} live entries${archivedRunCountLabel})` : 'Open Session Flight Recorder'; + const hasActiveReading = hasActiveSymbolicMomentReading(messages); const shouldShowAlignmentCorridor = alignmentCorridorState !== 'done' || sessionActive; - const shouldShowTopBalanceMeter = Boolean(latestAssistantBalanceTag && !pendingSoloMirrorResolver); + const shouldShowTopBalanceMeter = Boolean(latestAssistantBalanceTag && !pendingSoloMirrorResolver && !hasActiveReading); const showDormantBalanceTag = !latestAssistantBalanceTag; - const hasDormantRibbon = !shouldShowAlignmentCorridor && !shouldShowTopBalanceMeter; + const hasDormantRibbon = !shouldShowAlignmentCorridor && !shouldShowTopBalanceMeter && !hasActiveReading; const shouldRenderExpandedRibbon = true; const alignmentCorridorStatusText = useMemo(() => { if (readCapacityBlocked) return "Instrument cooling cycle active."; @@ -5002,6 +5035,7 @@ export default function App() { timestamp={msg.createdAt} sessionPhase={isLastAssistantMsg ? (session?.phase ?? null) : null} approachingWrap={Boolean(isLastAssistantMsg && !session?.isSealed && (session?.phase === 'vector' || session?.phase === 'seal'))} + debugMode={ravenBuilderModeEnabled} /> ) : ( {bubbleText} diff --git a/vessel/src/components/chat/RavenStructuredReply.tsx b/vessel/src/components/chat/RavenStructuredReply.tsx index 3e1cb07a0..6947b69cb 100644 --- a/vessel/src/components/chat/RavenStructuredReply.tsx +++ b/vessel/src/components/chat/RavenStructuredReply.tsx @@ -3,6 +3,11 @@ import React, { useState } from 'react'; import { expandGeometricalScaffoldingBody } from '../../lib/raven/geometricalScaffoldingFootnotes'; import { ChatGlossaryMarkdown } from './ChatGlossaryMarkdown'; import { VerificationPingCard } from './VerificationPingCard'; +import { + SymbolicMomentReadingSurface, + type SymbolicMomentDriver, +} from './SymbolicMomentReadingSurface'; +import { extractDriversFromText } from '../../lib/raven/symbolicMomentCompositor'; import type { ResonanceFeedback } from './ResonanceValidationCard'; type RavenStructuredReplyProps = { @@ -14,6 +19,11 @@ type RavenStructuredReplyProps = { timestamp?: string | number | null; sessionPhase?: 'calibration' | 'tension' | 'integration' | 'vector' | 'seal' | null; approachingWrap?: boolean; + /** + * Builder / debug mode. When true, normal-mode guards are relaxed and + * scaffolding labels (Signal, Meter, Landing) are exposed. + */ + debugMode?: boolean; }; type StructuredSectionId = @@ -40,7 +50,12 @@ type StructuredSectionId = | 'mirror_flow' | 'integration_seal' | 'mirror_voice' - | 'socratic_closure'; + | 'socratic_closure' + | 'meter' + | 'landing' + | 'check' + | 'drivers' + | 'reading'; type StructuredSection = { id: StructuredSectionId; @@ -74,6 +89,11 @@ const SECTION_LABELS = [ 'Mirror Flow', 'Mirror Voice', 'Socratic Closure', + 'Meter', + 'Landing', + 'Check', + 'Drivers', + 'Reading', ] as const; const SECTION_ID_BY_LABEL: Record<(typeof SECTION_LABELS)[number], StructuredSectionId> = { @@ -98,6 +118,11 @@ const SECTION_ID_BY_LABEL: Record<(typeof SECTION_LABELS)[number], StructuredSec 'Interaction Blueprint': 'interaction_blueprint', 'Pressure Constellations': 'pressure_constellations', 'Instrument Panel': 'instrument_panel', + Meter: 'meter', + Landing: 'landing', + Check: 'check', + Drivers: 'drivers', + Reading: 'reading', 'Mirror Flow': 'mirror_flow', 'Integration Seal': 'integration_seal', 'Mirror Voice': 'mirror_voice', @@ -129,6 +154,11 @@ const SECTION_STYLE_BY_ID: Record = { integration_seal: 'border-fuchsia-400/20 bg-fuchsia-500/[0.05]', mirror_voice: 'border-emerald-400/20 bg-emerald-500/[0.05]', socratic_closure: 'border-fuchsia-400/20 bg-fuchsia-500/[0.05]', + meter: 'border-cyan-400/25 bg-cyan-500/[0.06]', + landing: 'border-cyan-400/25 bg-cyan-500/[0.06]', + check: 'border-fuchsia-400/20 bg-fuchsia-500/[0.05]', + drivers: 'border-slate-400/20 bg-slate-500/[0.04]', + reading: 'border-cyan-400/25 bg-cyan-500/[0.06]', }; function ContinueButton({ @@ -341,7 +371,88 @@ function renderFallbackBlock(text: string) { ); } -/** Section IDs that form the unified reading bubble (Symbolic Moment core). */ +interface ParsedMeterData { + label?: string; + magnitude?: number; + bias?: number; + volatility?: number; +} + +function parseSymbolicMeterData(body: string): ParsedMeterData { + const data: ParsedMeterData = {}; + const lines = body.split('\n'); + + for (const line of lines) { + const trimmed = line.trim().toLowerCase(); + const magMatch = trimmed.match(/mag[a-z]*:?\s*([\d.]+)/); + const biasMatch = trimmed.match(/bias:?\s*([+-]?[\d.]+)/); + const volMatch = trimmed.match(/vol[a-z]*:?\s*([\d.]+)/); + + if (magMatch) data.magnitude = parseFloat(magMatch[1]); + if (biasMatch) data.bias = parseFloat(biasMatch[1]); + if (volMatch) data.volatility = parseFloat(volMatch[1]); + } + + return data; +} + +interface ParsedLandingData { + chamber: number | string; + text: string; +} + +function parseLandingData(body: string): ParsedLandingData { + const lines = body.split('\n').map((l) => l.trim()).filter(Boolean); + const firstLine = lines[0] || ''; + + // Try to extract chamber number from first line like "(8)" or "The Core (8)" + const chamberMatch = firstLine.match(/\((\d+)\)/); + const chamber = chamberMatch ? parseInt(chamberMatch[1], 10) : firstLine; + + // Rest of the text is the landing description + const text = lines.length > 1 + ? lines.slice(1).join('\n') + : firstLine.replace(/\s*\(\d+\)/, ''); + + return { chamber, text }; +} + +function parseDriversSection(body: string): SymbolicMomentDriver[] { + const drivers: SymbolicMomentDriver[] = []; + const lines = body.split('\n').map((l) => l.trim()).filter(Boolean); + + let currentTier: SymbolicMomentDriver['tier'] = 'primary'; + + for (const line of lines) { + const lowerLine = line.toLowerCase(); + + if (lowerLine.includes('climate') || lowerLine.includes('background')) { + currentTier = 'climate'; + continue; + } + if (lowerLine.includes('weather') || lowerLine.includes('immediate')) { + currentTier = 'primary'; + continue; + } + if (lowerLine.includes('amplifier')) { + currentTier = 'amplifier'; + continue; + } + + // Skip markdown artifacts + if (line.match(/^[#*]/)) continue; + + const cleaned = line.replace(/^[·\-*]\s*/, ''); + const aspects = extractDriversFromText(cleaned); + for (const a of aspects) { + drivers.push({ ...a, tier: currentTier }); + } + } + + return drivers; +} + +/** Section IDs that form the unified reading bubble (Symbolic Moment core - old format). */ const READING_BODY_IDS = new Set([ 'signal', 'map', @@ -349,6 +460,24 @@ const READING_BODY_IDS = new Set([ 'translation', ]); +/** Section IDs for the new Symbolic Moment reading surface. */ +const SYMBOLIC_MOMENT_SURFACE_IDS = new Set([ + 'signal', + 'meter', + 'landing', + 'voice', + 'check', + 'drivers', + 'reading', +]); +const NEW_SYMBOLIC_MOMENT_SURFACE_IDS = new Set([ + 'meter', + 'landing', + 'check', + 'drivers', + 'reading', +]); + export function RavenStructuredReply({ text, onSSTLog, @@ -357,6 +486,7 @@ export function RavenStructuredReply({ timestamp, sessionPhase = null, approachingWrap = false, + debugMode = false, }: Readonly) { const sections = parseRavenStructuredReply(text); const [visibleCount, setVisibleCount] = useState(initialVisibleCount); @@ -369,15 +499,21 @@ export function RavenStructuredReply({ const resolvedVisibleCount = Math.min(visibleSections.length, sections.length); const hasMoreSections = visibleCount < sections.length; - // Group sections into three rendering buckets: - // 1. Reading body (signal/map/voice/translation) → single unified bubble - // 2. Everything else that isn't footnote or verification → per-section bubble (other read types) - // 3. Verification → ping card - // 4. Footnotes → muted aside(s) + // Check if this is a new Symbolic Moment reading surface format + const isSymbolicMomentSurface = visibleSections.some((s) => + NEW_SYMBOLIC_MOMENT_SURFACE_IDS.has(s.id), + ); + + // Group sections based on format type const readingBodySections = visibleSections.filter((s) => READING_BODY_IDS.has(s.id)); const standaloneSections = visibleSections.filter( - (s) => !READING_BODY_IDS.has(s.id) && s.id !== 'footnote' && s.id !== 'verification', + (s) => !READING_BODY_IDS.has(s.id) && !SYMBOLIC_MOMENT_SURFACE_IDS.has(s.id) && s.id !== 'footnote' && s.id !== 'verification', ); + + // Extract Symbolic Moment surface sections + const symbolicMomentSections = isSymbolicMomentSurface + ? visibleSections.filter((s) => SYMBOLIC_MOMENT_SURFACE_IDS.has(s.id)) + : []; const verificationSection = visibleSections.find((s) => s.id === 'verification'); const visibleFootnotes = visibleSections.filter((s) => s.id === 'footnote'); @@ -392,8 +528,49 @@ export function RavenStructuredReply({ /> )} - {/* Bubble 1 — Unified reading (signal + map + voice + translation as one flow) */} - {readingBodySections.length > 0 && ( + {/* New Symbolic Moment reading surface (preferred format) */} + {isSymbolicMomentSurface && symbolicMomentSections.length > 0 && (() => { + const readingSection = symbolicMomentSections.find((s) => s.id === 'reading'); + const signalSection = symbolicMomentSections.find((s) => s.id === 'signal'); + const landingSection = symbolicMomentSections.find((s) => s.id === 'landing'); + const voiceSection = symbolicMomentSections.find((s) => s.id === 'voice'); + const meterSection = symbolicMomentSections.find((s) => s.id === 'meter'); + const checkSection = symbolicMomentSections.find((s) => s.id === 'check'); + const driversSection = symbolicMomentSections.find((s) => s.id === 'drivers'); + + const meterData = meterSection ? parseSymbolicMeterData(meterSection.body) : undefined; + const landingData = landingSection ? parseLandingData(landingSection.body) : undefined; + const driversData = driversSection ? parseDriversSection(driversSection.body) : []; + + // Prefer the unified Reading section; otherwise fall back to assembled legacy sections + const unifiedReading = readingSection?.body?.trim() ?? ''; + const landingChamber = + landingData && typeof landingData.chamber === 'number' ? landingData.chamber : undefined; + + return ( + + ); + })()} + + {/* Legacy Symbolic Moment bubble (signal + map + voice + translation as one flow) */} + {!isSymbolicMomentSurface && readingBodySections.length > 0 && (
= 0 ? '+' : ''; + parts.push(`Bias ${sign}${meter.bias.toFixed(1)}`); + } + if (meter.volatility !== undefined) { + parts.push(`Vol ${meter.volatility.toFixed(1)}`); + } + return parts.join(' · '); +} + +function assembleLegacyReading( + sections?: SymbolicMomentReadingSurfaceProps['legacySections'], +): string { + if (!sections) return ''; + const parts: string[] = []; + if (sections.signal) parts.push(sections.signal.trim()); + if (sections.landing?.text) parts.push(sections.landing.text.trim()); + if (sections.voice) parts.push(sections.voice.trim()); + return parts.join(' ').trim(); +} + +function partitionDrivers(drivers: SymbolicMomentDriver[]): { + primary: SymbolicMomentDriver[]; + climate: SymbolicMomentDriver[]; + amplifier: SymbolicMomentDriver[]; +} { + return { + primary: drivers.filter((d) => d.tier === 'primary' || !d.tier), + climate: drivers.filter((d) => d.tier === 'climate'), + amplifier: drivers.filter((d) => d.tier === 'amplifier'), + }; +} + +function DriverList({ + title, + drivers, +}: Readonly<{ + title: string; + drivers: SymbolicMomentDriver[]; +}>) { + if (drivers.length === 0) return null; + return ( +
+

+ {title} +

+
    + {drivers.map((d, i) => ( +
  • + · + + + {d.planetA} {d.aspect} {d.planetB} + + {d.note ? — {d.note} : null} + +
  • + ))} +
+
+ ); +} + +export function SymbolicMomentReadingSurface({ + reading, + check, + meter, + landingChamber, + drivers = [], + debugMode = false, + timestamp, + legacySections, +}: Readonly) { + const [showDrivers, setShowDrivers] = React.useState(false); + + const rawReading = reading?.trim() || assembleLegacyReading(legacySections); + const rawCheck = check?.trim() ?? ''; + const displayReading = debugMode + ? rawReading + : scrubBannedTermsForFrontstage(rawReading); + const displayCheck = debugMode + ? rawCheck + : scrubBannedTermsForFrontstage(rawCheck); + const meterLine = buildMeterLine(meter); + const { primary, climate, amplifier } = partitionDrivers(drivers); + const chamberLabel = + landingChamber && CHAMBER_META[landingChamber] + ? formatChamberLabel(landingChamber) + : null; + const hasAnyDrivers = drivers.length > 0 || chamberLabel; + + return ( +
+ {/* Header row: timestamp + compact meter as secondary status */} + {(timestamp || meterLine) && ( +
+ {meterLine ? ( +

+ {meterLine} +

+ ) : ( + + )} + {timestamp ? ( + + {new Date(timestamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + })} + + ) : null} +
+ )} + + {/* Main body: one continuous astrological paragraph */} + {displayReading && ( +
+ {displayReading} +
+ )} + + {/* Narrow validation question, directly beneath the paragraph */} + {displayCheck && ( +
+

+ {displayCheck} +

+
+ )} + + {/* Why this read? — collapsed disclosure with named sky + aspect breakdown */} + {hasAnyDrivers && ( +
+ + + {showDrivers && ( +
+ {chamberLabel && ( +
+

+ Landing +

+

+ {chamberLabel} + {landingChamber && CHAMBER_META[landingChamber] ? ( + + {' '} + — {CHAMBER_META[landingChamber].lifeDomain} + + ) : null} +

+
+ )} + + + + + + {debugMode && ( +
+

+ Builder Debug +

+
    + {drivers.map((d, i) => ( +
  • + {phraseForAspect(d.planetA, d.aspect, d.planetB)} +
  • + ))} +
+
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/vessel/src/components/chat/__tests__/RavenStructuredReply.test.tsx b/vessel/src/components/chat/__tests__/RavenStructuredReply.test.tsx index 29e72741e..6d2c488f9 100644 --- a/vessel/src/components/chat/__tests__/RavenStructuredReply.test.tsx +++ b/vessel/src/components/chat/__tests__/RavenStructuredReply.test.tsx @@ -265,6 +265,53 @@ test('RavenStructuredReply expands raw geometrical scaffolding labels into brief assert.match(html, /Old habits or old obligations are becoming harder to carry the old way \(Saturn square South Node\)\./); }); +test('RavenStructuredReply routes new Symbolic Moment sections to the reading surface and scrubs frontstage copy', () => { + const html = renderToStaticMarkup( + Mag: 2.4
', + '
This sealed geometry is visible.
', + '
Does the sealed geometry match what is happening?
', + '
Immediate Weather\n- Saturn square North Node
', + ].join('\n')} + />, + ); + + assert.match(html, /data-reading-surface="symbolic-moment"/); + assert.match(html, /Why this read\?/); + assert.doesNotMatch(html, /sealed geometry/i); + assert.doesNotMatch(html, /\bgeometry\b/i); +}); + +test('RavenStructuredReply keeps legacy Symbolic Moment replies in the legacy bubble', () => { + const html = renderToStaticMarkup( + , + ); + + assert.doesNotMatch(html, /data-reading-surface="symbolic-moment"/); + assert.match(html, /data-raven-section="signal"/); + assert.match(html, /data-raven-section="map"/); + assert.match(html, /data-raven-section="voice"/); +}); + test('parseRavenStructuredReply detects legacy relational cadence sections', () => { const sections = parseRavenStructuredReply([ 'Preface', diff --git a/vessel/src/lib/raven/symbolicMomentCompositor.ts b/vessel/src/lib/raven/symbolicMomentCompositor.ts new file mode 100644 index 000000000..67de1e8ee --- /dev/null +++ b/vessel/src/lib/raven/symbolicMomentCompositor.ts @@ -0,0 +1,249 @@ +/** + * Symbolic Moment reading compositor. + * + * Takes sealed geometry (drivers, meter, landing chamber) and produces + * ONE continuous astrological paragraph + narrow validation check. + * + * This is the voice layer. It does NOT change the math. It reshapes + * how Raven speaks after the geometry is sealed. + * + * Structure of a composed reading: + * 1. Opening line that names the weight of the day + * 2. Primary aspect phrase with named planets + * 3. Background driver phrase (climate) + * 4. Landing sentence that translates the chamber into life-domain + * 5. One narrow falsifiable check + */ + +import { + chamberLifeDomain, + chamberShortForm, + CHAMBER_META, + phraseForAspect, +} from './symbolicMomentTranslation'; + +export interface AspectDriver { + planetA: string; + aspect: string; + planetB: string; + tier?: 'primary' | 'climate' | 'amplifier'; +} + +export interface SymbolicMomentGeometry { + drivers: AspectDriver[]; + magnitude?: number; + bias?: number; + volatility?: number; + landingChamber?: number; +} + +export interface ComposedReading { + paragraph: string; + check: string; + meterLine: string; + /** Structured record kept for the "Why this read?" disclosure. */ + disclosure: { + climate: AspectDriver[]; + weather: AspectDriver[]; + amplifier: AspectDriver[]; + chamberName: string | null; + chamberLifeDomain: string | null; + }; +} + +const MAGNITUDE_WEIGHT_PHRASE: Array<{ min: number; phrase: string }> = [ + { min: 4.0, phrase: 'Today carries significant astrological weight.' }, + { min: 3.0, phrase: 'Today has real astrological weight.' }, + { min: 2.0, phrase: 'Today carries a noticeable astrological signature.' }, + { min: 1.0, phrase: 'Today carries a lighter astrological signature.' }, + { min: 0, phrase: 'Today sits quietly in the sky, astrologically speaking.' }, +]; + +function openingLine(magnitude?: number): string { + if (magnitude === undefined) return MAGNITUDE_WEIGHT_PHRASE[1].phrase; + for (const entry of MAGNITUDE_WEIGHT_PHRASE) { + if (magnitude >= entry.min) return entry.phrase; + } + return MAGNITUDE_WEIGHT_PHRASE.at(-1)!.phrase; +} + +function partitionDrivers(drivers: AspectDriver[]): { + primary: AspectDriver[]; + climate: AspectDriver[]; + amplifier: AspectDriver[]; +} { + const primary = drivers.filter((d) => d.tier === 'primary' || !d.tier); + const climate = drivers.filter((d) => d.tier === 'climate'); + const amplifier = drivers.filter((d) => d.tier === 'amplifier'); + return { primary, climate, amplifier }; +} + +function joinAspectPhrases(drivers: AspectDriver[], max: number): string { + const phrases = drivers + .slice(0, max) + .map((d) => phraseForAspect(d.planetA, d.aspect, d.planetB)) + .filter(Boolean); + + if (phrases.length === 0) return ''; + if (phrases.length === 1) return phrases[0]; + if (phrases.length === 2) return `${phrases[0]}, and ${phrases[1]}`; + return `${phrases.slice(0, -1).join(', ')}, and ${phrases.at(-1)}`; +} + +function formatOrdinalHouseLabel(chamber: number): string { + const mod100 = chamber % 100; + if (mod100 >= 11 && mod100 <= 13) { + return `${chamber}th-house`; + } + + switch (chamber % 10) { + case 1: + return `${chamber}st-house`; + case 2: + return `${chamber}nd-house`; + case 3: + return `${chamber}rd-house`; + default: + return `${chamber}th-house`; + } +} + +function buildLandingSentence(chamber?: number): string { + if (!chamber || !CHAMBER_META[chamber]) return ''; + const meta = CHAMBER_META[chamber]; + return `This looks more likely to land in ${formatOrdinalHouseLabel(chamber)} matters — ${meta.name} — around ${meta.lifeDomain}, where things may feel both weightier and harder to read clearly than usual.`; +} + +function buildLandingShortForm(chamber?: number): string { + if (!chamber || !CHAMBER_META[chamber]) return 'today'; + return chamberShortForm(chamber); +} + +function buildCheckQuestion(chamber?: number): string { + if (!chamber || !CHAMBER_META[chamber]) { + return 'Does that feel true today, or does it miss?'; + } + return `Is that where the pressure is landing — in ${chamberShortForm(chamber)} — or does it miss?`; +} + +function buildMeterLine(g: SymbolicMomentGeometry): string { + const parts: string[] = ['Symbolic Moment']; + if (g.magnitude !== undefined) parts.push(`Mag ${g.magnitude.toFixed(1)}`); + if (g.bias !== undefined) { + const sign = g.bias >= 0 ? '+' : ''; + parts.push(`Bias ${sign}${g.bias.toFixed(1)}`); + } + if (g.volatility !== undefined) parts.push(`Vol ${g.volatility.toFixed(1)}`); + return parts.join(' · '); +} + +/** + * Compose a single flowing astrological paragraph from sealed geometry. + * + * The paragraph follows this shape: + * [opening weight] + [primary aspect with named planets] + + * [climate/amplifier drivers deepening the background] + + * [landing translation into life-domain] + * + * Output is 3–5 sentences max. Named astrology remains visible. + * No internal engine terms. No protocol exposition. + */ +export function composeSymbolicMomentReading( + geometry: SymbolicMomentGeometry, +): ComposedReading { + const { primary, climate, amplifier } = partitionDrivers(geometry.drivers); + + const opening = openingLine(geometry.magnitude); + const primaryPhrase = primary.length > 0 ? joinAspectPhrases(primary, 1) : ''; + const backgroundDrivers = [...climate, ...amplifier]; + const backgroundPhrase = backgroundDrivers.length > 0 + ? joinAspectPhrases(backgroundDrivers, 2) + : ''; + + const landingShort = buildLandingShortForm(geometry.landingChamber); + + // Assemble the paragraph using a template that fuses primary + background + landing. + const pieces: string[] = [opening]; + + if (primaryPhrase && backgroundPhrase) { + pieces.push(`${primaryPhrase}.`); + pieces.push( + `With ${backgroundPhrase} deepening the background, this looks more likely to land in ${landingShort}, where things may feel both weightier and harder to read clearly than they should.`, + ); + } else if (primaryPhrase) { + pieces.push(`${primaryPhrase}.`); + if (geometry.landingChamber) { + pieces.push(buildLandingSentence(geometry.landingChamber)); + } + } else if (backgroundPhrase) { + pieces.push( + `${backgroundPhrase}, which can deepen the background and make the day harder to read clearly than usual.`, + ); + if (geometry.landingChamber) { + pieces.push(buildLandingSentence(geometry.landingChamber)); + } + } else if (geometry.landingChamber) { + pieces.push(buildLandingSentence(geometry.landingChamber)); + } + + const paragraph = pieces.join(' ').replaceAll(/\s+\./g, '.').trim(); + const check = buildCheckQuestion(geometry.landingChamber); + const meterLine = buildMeterLine(geometry); + + const chamberName = geometry.landingChamber + ? CHAMBER_META[geometry.landingChamber]?.name ?? null + : null; + const chamberLife = geometry.landingChamber + ? chamberLifeDomain(geometry.landingChamber) + : null; + + return { + paragraph, + check, + meterLine, + disclosure: { + climate, + weather: primary, + amplifier, + chamberName, + chamberLifeDomain: chamberLife, + }, + }; +} + +/** + * Parse a loose text blob that might contain structured hints like + * "Sun square Sun" or "Neptune opposition Pluto" into AspectDrivers. + * Used when the sealed geometry arrives as free text from the backend. + */ +export function extractDriversFromText(text: string): AspectDriver[] { + const drivers: AspectDriver[] = []; + const normalizePlanetKey = (planet: string): string => { + const compact = planet.replace(/\s+/g, ''); + if (compact === 'NorthNode') { + return 'North Node'; + } + if (compact === 'SouthNode') { + return 'South Node'; + } + return planet.trim(); + }; + const planetGroup = + '(Sun|Moon|Mercury|Venus|Mars|Jupiter|Saturn|Uranus|Neptune|Pluto|Chiron|North\\s*Node|South\\s*Node)'; + const aspectGroup = + '(conjunction|opposition|square|trine|sextile|quincunx|semisquare|sesquiquadrate|conjunct|opposite|squaring)'; + const pattern = new RegExp(`${planetGroup}\\s+${aspectGroup}\\s+${planetGroup}`, 'gi'); + + for (const match of text.matchAll(pattern)) { + const [, planetARaw, aspectRaw, planetBRaw] = match; + const planetA = normalizePlanetKey(planetARaw); + const planetB = normalizePlanetKey(planetBRaw); + const aspect = aspectRaw.toLowerCase() + .replace('conjunct', 'conjunction') + .replace('opposite', 'opposition') + .replace('squaring', 'square'); + drivers.push({ planetA, aspect, planetB }); + } + + return drivers; +} diff --git a/vessel/src/lib/raven/symbolicMomentFormatting.ts b/vessel/src/lib/raven/symbolicMomentFormatting.ts new file mode 100644 index 000000000..c07caa1b1 --- /dev/null +++ b/vessel/src/lib/raven/symbolicMomentFormatting.ts @@ -0,0 +1,446 @@ +/** + * Symbolic Moment copy validation and formatting. + * Enforces length constraints, detects duplicate semantic lines, + * and prevents overstatement relative to meter intensity. + */ + +import { BANNED_FRONTSTAGE_TERMS } from './symbolicMomentTranslation'; + +export interface SignalValidationResult { + isValid: boolean; + text: string; + warnings: string[]; +} + +export interface VoiceValidationResult { + isValid: boolean; + text: string; + warnings: string[]; + lineCount: number; +} + +export interface LandingValidationResult { + isValid: boolean; + text: string; + warnings: string[]; + sentenceCount: number; +} + +export interface SymbolicMomentCopyValidation { + signal: SignalValidationResult; + landing: LandingValidationResult; + voice: VoiceValidationResult; + isCoherent: boolean; +} + +const SIGNAL_MAX_LENGTH = 150; +const VOICE_MAX_LINES = 2; +const VOICE_SOFT_CHAR_LIMIT = 300; +const LANDING_SOFT_CHAR_LIMIT = 180; + +function countLines(text: string): number { + return text.split('\n').filter((line) => line.trim()).length; +} + +function countSentences(text: string): number { + return text.split(/[.!?]+/).filter((s) => s.trim()).length; +} + +function extractKeyPhrases(text: string): string[] { + // Simple extraction: split by common delimiters and normalize + return text + .toLowerCase() + .split(/[,;:\n]/) + .map((phrase) => phrase.trim()) + .filter((phrase) => phrase.length > 3); +} + +function detectDuplicateSemanticLines(text: string): string[] { + const lines = text + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + if (lines.length < 2) return []; + + const duplicates: string[] = []; + for (let i = 0; i < lines.length; i++) { + for (let j = i + 1; j < lines.length; j++) { + const similarity = calculateSimilarity(lines[i], lines[j]); + if (similarity > 0.65) { + duplicates.push(`Line ${i + 1} and ${j + 1} repeat similar ideas`); + } + } + } + + return duplicates; +} + +function calculateSimilarity(text1: string, text2: string): number { + const words1 = new Set(text1.toLowerCase().split(/\s+/)); + const words2 = new Set(text2.toLowerCase().split(/\s+/)); + + const intersection = new Set([...words1].filter((w) => words2.has(w))); + const union = new Set([...words1, ...words2]); + + return intersection.size / (union.size || 1); +} + +function metersupportsMeterIntensity( + magnetude: number | undefined, + textIntensityMarkers: string[], +): boolean { + if (magnetude === undefined) return true; + + const intensityKeywords = [ + 'significant', + 'substantial', + 'major', + 'intense', + 'powerful', + 'strong', + 'severe', + ]; + const quietKeywords = [ + 'subtle', + 'quiet', + 'gentle', + 'soft', + 'light', + 'mild', + 'faint', + ]; + + const textLower = textIntensityMarkers.join(' ').toLowerCase(); + const hasIntenseMarkers = intensityKeywords.some((kw) => textLower.includes(kw)); + const hasQuietMarkers = quietKeywords.some((kw) => textLower.includes(kw)); + + // If magnitude is high (3.5+) and text is very quiet, that's inconsistent + if (magnetude >= 3.5 && hasQuietMarkers && !hasIntenseMarkers) { + return false; + } + + // If magnitude is low (<1.5) and text is very intense, that's inconsistent + if (magnetude < 1.5 && hasIntenseMarkers && !hasQuietMarkers) { + return false; + } + + return true; +} + +export function validateSignal( + text: string, + expectedMagnitude?: number, +): SignalValidationResult { + const trimmed = text.trim(); + const warnings: string[] = []; + + if (trimmed.length > SIGNAL_MAX_LENGTH) { + warnings.push( + `Signal exceeds ${SIGNAL_MAX_LENGTH} characters (${trimmed.length} chars). Keep it to one plain line.`, + ); + } + + const intensityMarkers = extractKeyPhrases(trimmed); + if (!metersupportsMeterIntensity(expectedMagnitude, intensityMarkers)) { + warnings.push( + 'Signal intensity does not align with meter readings. Adjust language or meter.', + ); + } + + return { + isValid: warnings.length === 0, + text: trimmed, + warnings, + }; +} + +export function validateLanding(text: string): LandingValidationResult { + const trimmed = text.trim(); + const warnings: string[] = []; + const sentenceCount = countSentences(trimmed); + + if (trimmed.length > LANDING_SOFT_CHAR_LIMIT) { + warnings.push( + `Landing exceeds soft limit of ${LANDING_SOFT_CHAR_LIMIT} characters. Consider shortening.`, + ); + } + + if (sentenceCount > 2) { + warnings.push('Landing should be one grounded sentence or statement. Split into fewer lines.'); + } + + const duplicates = detectDuplicateSemanticLines(trimmed); + warnings.push(...duplicates); + + return { + isValid: warnings.length === 0, + text: trimmed, + warnings, + sentenceCount, + }; +} + +export function validateVoice(text: string): VoiceValidationResult { + const trimmed = text.trim(); + const warnings: string[] = []; + const lineCount = countLines(trimmed); + + if (lineCount > VOICE_MAX_LINES) { + warnings.push( + `Voice has ${lineCount} paragraphs but should be max 2. Compress or split into separate checks.`, + ); + } + + if (trimmed.length > VOICE_SOFT_CHAR_LIMIT) { + warnings.push( + `Voice exceeds soft limit of ${VOICE_SOFT_CHAR_LIMIT} characters. Keep prose calm and specific.`, + ); + } + + const duplicates = detectDuplicateSemanticLines(trimmed); + warnings.push(...duplicates); + + if (/(paraphras|restat|again|similarly|likewise|also)/i.test(trimmed)) { + warnings.push('Voice may contain repeated ideas. Remove paraphrasing and stay specific.'); + } + + return { + isValid: warnings.length === 0, + text: trimmed, + warnings, + lineCount, + }; +} + +export function validateSymbolicMomentCopy(input: { + signal?: string; + landing?: string; + voice?: string; + magnitude?: number; +}): SymbolicMomentCopyValidation { + const signal = validateSignal(input.signal || '', input.magnitude); + const landing = validateLanding(input.landing || ''); + const voice = validateVoice(input.voice || ''); + + const isCoherent = + signal.isValid && landing.isValid && voice.isValid; + + return { + signal, + landing, + voice, + isCoherent, + }; +} + +// --------------------------------------------------------------------------- +// Unified reading validation (voice correction pass) +// --------------------------------------------------------------------------- + +export interface UnifiedReadingValidation { + isValid: boolean; + text: string; + sentenceCount: number; + warnings: string[]; + bannedTermsFound: string[]; + hasNamedAspect: boolean; + hasLifeDomain: boolean; + endsWithQuestion: boolean; +} + +const READING_MIN_SENTENCES = 2; +const READING_MAX_SENTENCES = 5; + +const NAMED_ASPECT_PATTERN = + /\b(Sun|Moon|Mercury|Venus|Mars|Jupiter|Saturn|Uranus|Neptune|Pluto|Chiron|North\s*Node|South\s*Node)\b\s+(?:conjunction|opposition|square|trine|sextile|quincunx|conjunct|opposite|squaring|semisquare|sesquiquadrate)\s+\b(Sun|Moon|Mercury|Venus|Mars|Jupiter|Saturn|Uranus|Neptune|Pluto|Chiron|North\s*Node|South\s*Node)\b/i; + +const LIFE_DOMAIN_PATTERN = + /\b(money|trust|intimacy|obligation|home|family|work|career|partnership|health|body|speech|creativity|routine|belonging|travel|study|meaning|solitude|inheritance|obligations|resources|children|communication|authority|alliances|standing|value|values)\b/i; + +function detectBannedTerms(text: string): string[] { + const textLower = text.toLowerCase(); + const found: string[] = []; + for (const term of BANNED_FRONTSTAGE_TERMS) { + const termLower = term.toLowerCase(); + // Match on word boundary when term is a single word, else substring + if (/\s/.test(termLower)) { + if (textLower.includes(termLower)) found.push(term); + } else { + const pattern = new RegExp(`\\b${termLower.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`); + if (pattern.test(textLower)) found.push(term); + } + } + return found; +} + +/** + * Validate a unified reading paragraph under the voice-correction rules. + * + * Hard rules enforced: + * - 2–5 sentences + * - contains at least one named planetary aspect + * - contains at least one grounded life-domain referent + * - no banned frontstage terms + * - avoids banned tone patterns + * - avoids imperative / prediction / advisory phrasing + */ +export function validateUnifiedReading( + text: string, + options?: { magnitude?: number; debugMode?: boolean }, +): UnifiedReadingValidation { + const trimmed = text.trim(); + const warnings: string[] = []; + const sentenceCount = countSentences(trimmed); + const debugMode = options?.debugMode ?? false; + + if (sentenceCount < READING_MIN_SENTENCES) { + warnings.push( + `Reading is too short (${sentenceCount} sentence${sentenceCount === 1 ? '' : 's'}). Aim for ${READING_MIN_SENTENCES}–${READING_MAX_SENTENCES}.`, + ); + } + if (sentenceCount > READING_MAX_SENTENCES) { + warnings.push( + `Reading is too long (${sentenceCount} sentences). Cap at ${READING_MAX_SENTENCES}.`, + ); + } + + const hasNamedAspect = NAMED_ASPECT_PATTERN.test(trimmed); + if (!hasNamedAspect) { + warnings.push( + 'Reading contains no named planetary aspect. Keep the astrology visible (e.g., "Sun square Sun", "Neptune opposite Pluto").', + ); + } + + const hasLifeDomain = LIFE_DOMAIN_PATTERN.test(trimmed); + if (!hasLifeDomain) { + warnings.push( + 'Reading has no grounded life-domain referent. Translate the chamber into recognizable life terms (money, trust, intimacy, work, home, etc.).', + ); + } + + const bannedTermsFound = debugMode ? [] : detectBannedTerms(trimmed); + if (bannedTermsFound.length > 0) { + warnings.push( + `Reading uses banned frontstage terms: ${bannedTermsFound.join(', ')}. These belong in debug/builder mode, not the active reading.`, + ); + } + + // Imperative / advisory flags + if (/\b(you should|you must|make sure to|try to|consider doing|do not|don't forget)\b/i.test(trimmed)) { + warnings.push( + 'Reading contains imperative/advisory language. Describe the symbolism, do not direct the reader.', + ); + } + + // Prediction flags + if (/\b(will happen|is going to|expect to|will encounter|guaranteed|certainly will)\b/i.test(trimmed)) { + warnings.push( + 'Reading contains prediction language. Describe conditional symbolism only.', + ); + } + + // Guessing-from-testimony flags + if (/\b(it sounds like you|based on what you said|from what you're describing)\b/i.test(trimmed)) { + warnings.push( + 'Reading is guessing from testimony rather than speaking from geometry.', + ); + } + + // Repetition detection + const duplicates = detectDuplicateSemanticLines(trimmed); + warnings.push(...duplicates); + + const intensityMarkers = extractKeyPhrases(trimmed); + if (!metersupportsMeterIntensity(options?.magnitude, intensityMarkers)) { + warnings.push( + 'Reading intensity does not align with meter magnitude. Adjust tone or meter.', + ); + } + + const endsWithQuestion = /\?\s*$/.test(trimmed); + + return { + isValid: warnings.length === 0, + text: trimmed, + sentenceCount, + warnings, + bannedTermsFound, + hasNamedAspect, + hasLifeDomain, + endsWithQuestion, + }; +} + +/** + * Validate a narrow check question. A good check is short, falsifiable, + * and asks whether the symbolism is landing — not whether the user feels good. + */ +export function validateCheckQuestion(text: string): { + isValid: boolean; + warnings: string[]; +} { + const trimmed = text.trim(); + const warnings: string[] = []; + + if (!/\?\s*$/.test(trimmed)) { + warnings.push('Check must end with a question mark.'); + } + + if (trimmed.length > 200) { + warnings.push('Check is too long. Keep it narrow and falsifiable.'); + } + + if (/\b(how are you feeling|tell me about|would you like)\b/i.test(trimmed)) { + warnings.push('Check sounds like a therapy prompt, not a falsification question.'); + } + + if (countSentences(trimmed) > 2) { + warnings.push('Check should be a single narrow question.'); + } + + return { isValid: warnings.length === 0, warnings }; +} + +/** + * Strip banned frontstage terms from a string for rendering in normal + * user mode. Returns a scrubbed version with banned terms softened + * or removed. Debug mode should skip this step. + */ +export function scrubBannedTermsForFrontstage(text: string): string { + let scrubbed = text; + const replacements: Record = { + 'the field': 'the sky', + 'field is': 'the sky is', + field: '', + 'line of pressure': '', + 'pressure line': '', + 'structural load': 'weight', + 'structural pressure': 'weight', + 'compressed and fragmented': 'weightier and harder to read clearly', + fragmentation: '', + fragmented: '', + compressed: '', + sealed: '', + 'sealed geometry': '', + pipeline: '', + corridor: '', + handshake: '', + }; + + const orderedReplacements = Object.entries(replacements).sort( + ([left], [right]) => right.length - left.length, + ); + + for (const [banned, replacement] of orderedReplacements) { + const pattern = new RegExp(`\\b${banned}\\b`, 'gi'); + scrubbed = scrubbed.replace(pattern, replacement); + } + + // Collapse leftover double-spaces and double-punctuation + return scrubbed + .replaceAll(/\s+/g, ' ') + .replaceAll(/\s+([,.;:!?])/g, '$1') + .replaceAll(/,\s*,/g, ',') + .trim(); +} diff --git a/vessel/src/lib/raven/symbolicMomentTranslation.ts b/vessel/src/lib/raven/symbolicMomentTranslation.ts new file mode 100644 index 000000000..ec884788f --- /dev/null +++ b/vessel/src/lib/raven/symbolicMomentTranslation.ts @@ -0,0 +1,255 @@ +/** + * Astrological translation tables for the Symbolic Moment reading voice. + * + * These tables translate sealed geometry (aspects, planets, chambers) into + * readable symbolic language. They are used by the reading compositor to + * produce one continuous astrological paragraph — not segmented chunks. + * + * Voice rules: + * - Keep named astrology visible in the prose. + * - Translate chambers with immediate earthly referents. + * - Avoid internal-engine terms (field, pressure line, structural load). + * - No imperative, prediction, or advisory language. + */ + +/** + * Planet → symbolic-consequence translation. + * Each entry is a short phrase Raven can use to render a planet's + * symbolism in accessible language. + */ +export const PLANET_CONSEQUENCE: Record = { + Sun: 'will, identity, and direction', + Moon: 'inner weather and emotional tone', + Mercury: 'thinking, speech, and quick decisions', + Venus: 'connection, value, and what feels close', + Mars: 'drive, friction, and what pushes forward', + Jupiter: 'scale, reach, and magnification', + Saturn: 'weight, boundary, and what holds or constrains', + Uranus: 'sudden shift, disruption, and break from expectation', + Neptune: 'blur, dissolution, and what edges soften', + Pluto: 'depth, intensity, and what concentrates below the surface', + Chiron: 'wound-sensitivity and where care is needed', + NorthNode: 'forward direction and what is pulling into shape', + 'North Node': 'forward direction and what is pulling into shape', +}; + +/** + * Aspect → quality translation. + * Describes how two planets relate when meeting at this angle. + */ +export const ASPECT_QUALITY: Record = { + conjunction: 'fuses and amplifies', + opposition: 'pulls against and polarizes', + square: 'creates friction and forces effort', + trine: 'flows and harmonizes', + sextile: 'opens and invites', + quincunx: 'adjusts at an awkward angle', + semisquare: 'grates quietly', + sesquiquadrate: 'catches unevenly', +}; + +/** + * Concise phrases for common aspect pairings that appear frequently + * in symbolic-moment readings. Used as fallback when the compositor + * needs a polished phrase. + */ +export const COMMON_ASPECT_PHRASE: Record = { + 'Sun square Sun': 'puts friction in the will, so even simple effort may take more than it should', + 'Sun opposition Sun': 'pulls the will into direct counter-tension', + 'Sun conjunction Sun': 'fuses identity and direction into a single concentrated line', + 'Neptune opposition Pluto': 'makes the deeper background harder to see clearly, softening edges where intensity is concentrating', + 'Neptune square Pluto': 'blurs the edges of what is concentrating in the depths', + 'Jupiter conjunction Pluto': 'magnifies scale and consequence, deepening reach', + 'Jupiter square Pluto': 'enlarges what is already intensifying', + 'Saturn square Sun': 'adds weight and resistance to ordinary self-direction', + 'Mars square Sun': 'puts friction in drive and action', +}; + +/** + * Chamber (house) → canonical Woven Map name + earthly life-domain + * referents. Canonical names match the full house-to-chamber map used + * throughout the prompt contract: 1/The Gate, 2/The Store, 3/The Path, + * 4/The Root, 5/The Forge, 6/The Field, 7/The Mirror, 8/The Core, + * 9/The Horizon, 10/The Canopy, 11/The Grove, 12/The Shell. + * + * Always pair the poetic name with concrete life areas so the reader + * has recognizable anchors. + */ +export const CHAMBER_META: Record< + number, + { name: string; lifeDomain: string; shortForm: string } +> = { + 1: { + name: 'The Gate', + lifeDomain: 'presentation, body, first impression, and how you meet the world', + shortForm: 'how you show up', + }, + 2: { + name: 'The Store', + lifeDomain: 'money, possessions, values, and what you rely on', + shortForm: 'money and what you value', + }, + 3: { + name: 'The Path', + lifeDomain: 'speech, siblings, short trips, and daily exchanges', + shortForm: 'daily speech and exchanges', + }, + 4: { + name: 'The Root', + lifeDomain: 'home, family, roots, and private ground', + shortForm: 'home and roots', + }, + 5: { + name: 'The Forge', + lifeDomain: 'children, creative expression, romance, and play', + shortForm: 'creativity and play', + }, + 6: { + name: 'The Field', + lifeDomain: 'work, routine, body, service, and day-to-day craft', + shortForm: 'work and daily routine', + }, + 7: { + name: 'The Mirror', + lifeDomain: 'one-on-one bonds, agreements, open adversaries, and committed others', + shortForm: 'close partnerships', + }, + 8: { + name: 'The Core', + lifeDomain: + 'shared money, trust, intimacy, obligation, inheritance, and heavier emotional undercurrents', + shortForm: 'shared money, trust, intimacy, obligation', + }, + 9: { + name: 'The Horizon', + lifeDomain: 'travel, study, belief, higher meaning, and distance', + shortForm: 'meaning and distance', + }, + 10: { + name: 'The Canopy', + lifeDomain: 'career, public standing, authority, and visible direction', + shortForm: 'career and public standing', + }, + 11: { + name: 'The Grove', + lifeDomain: 'groups, friendships, allies, and shared aims', + shortForm: 'groups and alliances', + }, + 12: { + name: 'The Shell', + lifeDomain: 'solitude, hidden matters, what is ending, and the inner underside', + shortForm: 'solitude and what is hidden', + }, +}; + +/** + * Terms banned from frontstage (normal user mode) reading prose. + * These betray the internal engine, protocol, or dashboard metaphor + * and make Raven sound like a weather app or UX explainer instead + * of an astrologer. + * + * Debug/builder mode may still display these. + */ +export const BANNED_FRONTSTAGE_TERMS: readonly string[] = [ + // Internal engine vocabulary + 'field', + 'current', + 'line of pressure', + 'pressure line', + 'structural load', + 'structural pressure', + 'fragmentation', + 'fragmented', + 'compressed', + 'compression', + 'pipeline', + 'payload', + 'instrument', + 'instrument panel', + 'readout', + 'meter reading', + 'corridor', + 'geometry', + 'handshake', + 'signal', + 'MAP', + 'VOICE', + 'FIELD', + 'sealed', + 'sealed geometry', + + // Protocol / UX exposition + 'protocol', + 'this is showing up as', + 'let me explain', + "here's what this means", + 'the system shows', + 'raven detects', + 'the read indicates', + 'reading process', + + // Weather-app metaphor + 'weather forecast', + 'forecast', + 'climate today', + + // Therapeutic chatbot + 'how are you feeling', + 'would you like to talk about', + "let's process", + + // Vague detached mood talk (to be checked, not strict-ban) + 'something feels off', + 'feels weird', +] as const; + +/** + * Short canonical label for a chamber used in disclosure panels. + * Always include both name and number. + */ +export function formatChamberLabel(house: number): string { + const meta = CHAMBER_META[house]; + if (!meta) return `House ${house}`; + return `${meta.name} (${house}th house)`; +} + +/** + * Return chamber life-domain phrase for embedding in reading prose. + * Example: house 8 → "shared money, trust, intimacy, obligation, inheritance…" + */ +export function chamberLifeDomain(house: number): string { + return CHAMBER_META[house]?.lifeDomain ?? `matters related to the ${house}th house`; +} + +/** + * Return the short chamber form suited to inline prose. + */ +export function chamberShortForm(house: number): string { + return CHAMBER_META[house]?.shortForm ?? `${house}th-house matters`; +} + +/** + * Return a polished phrase for an aspect pair if one exists, + * else fall back to composed quality + planets. + */ +export function phraseForAspect( + planetA: string, + aspect: string, + planetB: string, +): string { + const keyDirect = `${planetA} ${aspect} ${planetB}`; + const keyReverse = `${planetB} ${aspect} ${planetA}`; + + if (COMMON_ASPECT_PHRASE[keyDirect]) { + return `${keyDirect} ${COMMON_ASPECT_PHRASE[keyDirect]}`; + } + if (COMMON_ASPECT_PHRASE[keyReverse]) { + return `${keyReverse} ${COMMON_ASPECT_PHRASE[keyReverse]}`; + } + + const quality = ASPECT_QUALITY[aspect.toLowerCase()] ?? 'interacts with'; + const consequenceA = PLANET_CONSEQUENCE[planetA] ?? planetA; + const consequenceB = PLANET_CONSEQUENCE[planetB] ?? planetB; + + return `${planetA} ${aspect} ${planetB} ${quality} ${consequenceA} with ${consequenceB}`; +}