From 2c1fdbad2acd7fb7352974db68e80384c1125c9e Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:21:38 -0500 Subject: [PATCH 1/2] feat(raven): dynamic driver licensing with field-first narrative No fixed driver cap. No open floodgate. Dynamic licensing by mass, chamber resonance, question resonance, freshness, and short-window salience. Licensed means lawful to use, not obligated to speak. Field-first synthesis remains mandatory. Implementation: - Added DriverLicenseRole (primary, contextual, background, excluded) and DriverLicenseReason types - Implemented applyDynamicDriverLicensing() that licenses drivers based on magnitude, chamber resonance, question resonance, freshness, and short-window salience - Centralized claimMayBeNamed() as single source for "what Raven may name" - Expanded Math Brain driver pool from 5 to 8 - Wired questionCategory through pipeline for contextual licensing - Added field-first directive to systemBlockBuilder: open with measured pressure before naming aspects - Added dynamic driver law: licensed means lawful to use, not mandatory to mention - Added grounding directive: do not imply confirming event is owed - Updated terrain packet to show LICENSED DRIVERS with role/reason metadata - Propagated license metadata through GeometryDriver, DriverSummary, and client-facing types - Updated all UI surfaces to use claimMayBeNamed() instead of hard-coded isPeakDriver || tier !== 'minor' - Added 4 focused tests proving chamber resonance, question resonance, and field-first prompt guidance Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- vessel/src/app/api/raven-chat/route.ts | 1 + .../app/api/raven-chat/systemBlockBuilder.ts | 11 +- vessel/src/app/api/raven-chat/types.ts | 8 +- .../components/chat/ReadoutArtifactChip.tsx | 3 +- .../raven/__tests__/geometryArtifact.test.ts | 61 +++++++ .../renderPlanDriverAuthority.test.ts | 25 +++ vessel/src/lib/raven/coherenceDossier.ts | 3 +- .../src/lib/raven/currentGeometryArtifact.ts | 9 + vessel/src/lib/raven/geometryArtifact.ts | 163 +++++++++++++++++- vessel/src/lib/raven/readoutArtifact.ts | 21 ++- vessel/src/lib/raven/readoutArtifactClient.ts | 3 +- vessel/src/lib/raven/renderability.ts | 6 + .../src/lib/raven/subjectDriverAttribution.ts | 3 +- vessel/src/lib/raven/turnReadoutArtifact.ts | 3 + vessel/src/lib/v3MathBrain.ts | 2 +- 15 files changed, 303 insertions(+), 19 deletions(-) diff --git a/vessel/src/app/api/raven-chat/route.ts b/vessel/src/app/api/raven-chat/route.ts index 980744e97..5828d236b 100644 --- a/vessel/src/app/api/raven-chat/route.ts +++ b/vessel/src/app/api/raven-chat/route.ts @@ -4729,6 +4729,7 @@ async function runRavenChatPipeline(request: Request): Promise { counterpartProfile, relationalSharedStormReport, polyadicPerSubjectContexts, + questionCategory: resolvedCategory, }); if (turnReadoutArtifact && turnReadoutArtifact.artifactKind === 'turn_readout') { diff --git a/vessel/src/app/api/raven-chat/systemBlockBuilder.ts b/vessel/src/app/api/raven-chat/systemBlockBuilder.ts index 78824e3cc..605462c47 100644 --- a/vessel/src/app/api/raven-chat/systemBlockBuilder.ts +++ b/vessel/src/app/api/raven-chat/systemBlockBuilder.ts @@ -1189,8 +1189,12 @@ export function buildDumbPromptSegments(plan: RavenRenderPlan, config: SystemBlo `Signal Strength: ${plan.terrain.load.signalStrength} (Voltage: ${plan.terrain.load.systemVoltage.toFixed(1)})`, plan.terrain.signalVoidReason ? `Signal Void Reason: ${plan.terrain.signalVoidReason}` : '', ``, - `PRIMARY DRIVERS:`, - ...plan.terrain.primaryDrivers.map(d => `- ${d.planet}: ${d.activeAspects.join(', ')} (Mass: ${d.mass.toFixed(2)})`), + `LICENSED DRIVERS:`, + ...plan.terrain.primaryDrivers.map(d => { + const role = d.licenseRole ? ` | role=${d.licenseRole}` : ''; + const reasons = d.licenseReasons?.length ? ` | reasons=${d.licenseReasons.join(',')}` : ''; + return `- ${d.planet}: ${d.activeAspects.join(', ')} (Mass: ${d.mass.toFixed(2)}${role}${reasons})`; + }), `` ]), `=== BOUNDARIES ===`, @@ -1215,6 +1219,9 @@ export function buildDumbPromptSegments(plan: RavenRenderPlan, config: SystemBlo const instruction = [ `Use the certified terrain below. Do not invent geometry, chambers, events, motives, diagnoses, or timelines.`, + `Field-first directive: open with the measured pressure or lived thesis before naming individual aspects. Drivers are evidence for the field, not a menu to recite.`, + `Dynamic driver law: heavy drivers explain field magnitude; contextual drivers explain chamber or user-question texture. Licensed means lawful to use, not mandatory to mention.`, + `Grounding directive: do not imply a confirming event is owed. End Symbolic Moment reads with an open calibration question or clean landing; silence/no-contact is valid data.`, `Choose the clearest unifying thread and translate the pressure in Raven voice. Keep the reading accessible, clear, and at a 9th-grade reading level.`, ``, `--- CERTIFIED TERRAIN PACKET ---`, diff --git a/vessel/src/app/api/raven-chat/types.ts b/vessel/src/app/api/raven-chat/types.ts index d4f6f5cb9..7bf618b5b 100644 --- a/vessel/src/app/api/raven-chat/types.ts +++ b/vessel/src/app/api/raven-chat/types.ts @@ -529,11 +529,17 @@ export type ChamberDomain = | "THE_SHELL"; // 12th House export interface DriverSummary { - planet: "SUN" | "MOON" | "MERCURY" | "VENUS" | "MARS" | "JUPITER" | "SATURN" | "URANUS" | "NEPTUNE" | "PLUTO" | "CHIRON" | "SOUTH_NODE"; + planet: "SUN" | "MOON" | "MERCURY" | "VENUS" | "MARS" | "JUPITER" | "SATURN" | "URANUS" | "NEPTUNE" | "PLUTO" | "CHIRON" | "NORTH_NODE" | "SOUTH_NODE" | "LILITH" | "ASCENDANT" | "MEDIUM_COELI" | "UNKNOWN"; coordinate: string; mass: number; // Mass weighting assigned by Math Brain polarityBias: number; // Directional bias from -5.0 to +5.0 activeAspects: string[]; + licenseRole?: "primary" | "contextual" | "background" | "excluded"; + licenseReasons?: Array<"mass" | "salience" | "chamber_resonance" | "user_question_resonance" | "freshness" | "short_window_spike" | "sustained_frame">; + resonantChambers?: string[]; + mayMention?: boolean; + shouldMention?: boolean; + mustMention?: boolean; } export interface BackgroundSummary { diff --git a/vessel/src/components/chat/ReadoutArtifactChip.tsx b/vessel/src/components/chat/ReadoutArtifactChip.tsx index 7f75364d0..7083ac52f 100644 --- a/vessel/src/components/chat/ReadoutArtifactChip.tsx +++ b/vessel/src/components/chat/ReadoutArtifactChip.tsx @@ -10,6 +10,7 @@ import { type ReadoutArtifactClientPayload, buildEvidenceSummaryFromClient, } from '../../lib/raven/readoutArtifactClient'; +import { claimMayBeNamed } from '../../lib/raven/geometryArtifact'; import { meterDisplay } from '../../lib/raven/meterDisplay'; import { cn } from '../../lib/utils'; @@ -394,7 +395,7 @@ function GeometryDisclosure({ payload }: Readonly<{ payload: ReadoutArtifactClie function TurnReadoutChipBody({ payload }: Readonly<{ payload: ReadoutArtifactClientPayload }>) { const magnitude = meterDisplay(payload.balanceMeter.magnitude); const bias = meterDisplay(payload.balanceMeter.directionalBias); - const peakDrivers = payload.drivers.filter((d) => d.isPeakDriver || d.tier !== 'minor').slice(0, 2); + const peakDrivers = payload.drivers.filter(claimMayBeNamed).slice(0, 2); return ( <> diff --git a/vessel/src/lib/raven/__tests__/geometryArtifact.test.ts b/vessel/src/lib/raven/__tests__/geometryArtifact.test.ts index 6f645bee8..7cc731664 100644 --- a/vessel/src/lib/raven/__tests__/geometryArtifact.test.ts +++ b/vessel/src/lib/raven/__tests__/geometryArtifact.test.ts @@ -139,6 +139,67 @@ test('api-verified anchor rows win over payload summaries', () => { assert.equal(formatGeometryClaimLabel(claims[0]), 'Sun conjunction Saturn'); }); +test('dynamic licensing can admit a salient chamber-resonant minor driver', () => { + const claims = resolveLicensedGeometryClaims({ + date: '2026-06-21', + landingHouses: [3], + anchorTopDrivers: [ + { label: 'Saturn square Moon', x: 3.0, transitingPlanet: 'saturn', stationedPlanet: 'moon', aspectType: 'square' }, + { label: 'Pluto opposition Sun', x: 2.8, transitingPlanet: 'pluto', stationedPlanet: 'sun', aspectType: 'opposition' }, + { label: 'Sun conjunction Venus', x: 2.6, transitingPlanet: 'sun', stationedPlanet: 'venus', aspectType: 'conjunction' }, + { label: 'Jupiter trine Mars', x: 2.4, transitingPlanet: 'jupiter', stationedPlanet: 'mars', aspectType: 'trine' }, + { label: 'Venus sextile Jupiter', x: 2.2, transitingPlanet: 'venus', stationedPlanet: 'jupiter', aspectType: 'sextile' }, + { label: 'Mercury square Uranus', x: 0.7, transitingPlanet: 'mercury', stationedPlanet: 'uranus', aspectType: 'square' }, + ], + }); + + const mercury = claims.find((claim) => claim.transitingPlanet === 'mercury'); + assert.ok(mercury); + assert.equal(mercury?.tier, 'minor'); + assert.equal(mercury?.licenseRole, 'contextual'); + assert.ok(mercury?.licenseReasons?.includes('chamber_resonance')); + assert.ok(licensedDriverLabels(claims).includes('Mercury square Uranus')); +}); + +test('dynamic licensing keeps weak non-resonant minor drivers out of Raven voice', () => { + const claims = resolveLicensedGeometryClaims({ + date: '2026-06-21', + landingHouses: [9], + anchorTopDrivers: [ + { label: 'Saturn square Moon', x: 3.0, transitingPlanet: 'saturn', stationedPlanet: 'moon', aspectType: 'square' }, + { label: 'Pluto opposition Sun', x: 2.8, transitingPlanet: 'pluto', stationedPlanet: 'sun', aspectType: 'opposition' }, + { label: 'Sun conjunction Venus', x: 2.6, transitingPlanet: 'sun', stationedPlanet: 'venus', aspectType: 'conjunction' }, + { label: 'Jupiter trine Mars', x: 2.4, transitingPlanet: 'jupiter', stationedPlanet: 'mars', aspectType: 'trine' }, + { label: 'Venus sextile Jupiter', x: 2.2, transitingPlanet: 'venus', stationedPlanet: 'jupiter', aspectType: 'sextile' }, + { label: 'Mercury square Uranus', x: 0.2, transitingPlanet: 'mercury', stationedPlanet: 'uranus', aspectType: 'square' }, + ], + }); + + const mercury = claims.find((claim) => claim.transitingPlanet === 'mercury'); + assert.equal(mercury?.licenseRole, 'excluded'); + assert.ok(!licensedDriverLabels(claims).includes('Mercury square Uranus')); +}); + +test('question resonance can license local texture without promoting it to mandatory speech', () => { + const claims = resolveLicensedGeometryClaims({ + date: '2026-06-21', + questionCategory: 'COMMUNICATION_MESSAGE', + anchorTopDrivers: [ + { label: 'Saturn square Moon', x: 3.0, transitingPlanet: 'saturn', stationedPlanet: 'moon', aspectType: 'square' }, + { label: 'Pluto opposition Sun', x: 2.8, transitingPlanet: 'pluto', stationedPlanet: 'sun', aspectType: 'opposition' }, + { label: 'Sun conjunction Venus', x: 2.6, transitingPlanet: 'sun', stationedPlanet: 'venus', aspectType: 'conjunction' }, + { label: 'Jupiter trine Mars', x: 2.4, transitingPlanet: 'jupiter', stationedPlanet: 'mars', aspectType: 'trine' }, + { label: 'Venus sextile Jupiter', x: 2.2, transitingPlanet: 'venus', stationedPlanet: 'jupiter', aspectType: 'sextile' }, + { label: 'Mercury square Uranus', x: 0.4, transitingPlanet: 'mercury', stationedPlanet: 'uranus', aspectType: 'square' }, + ], + }); + + const mercury = claims.find((claim) => claim.transitingPlanet === 'mercury'); + assert.equal(mercury?.licenseRole, 'contextual'); + assert.equal(mercury?.mustMention, false); + assert.ok(mercury?.licenseReasons?.includes('user_question_resonance')); +}); + test('peak driver without natal receiver cannot seal as a claim', () => { const claims = buildGeometryClaimsFromPeakDrivers({ date: '2026-06-21', diff --git a/vessel/src/lib/raven/__tests__/renderPlanDriverAuthority.test.ts b/vessel/src/lib/raven/__tests__/renderPlanDriverAuthority.test.ts index 4ea92c6a4..b6a9c8253 100644 --- a/vessel/src/lib/raven/__tests__/renderPlanDriverAuthority.test.ts +++ b/vessel/src/lib/raven/__tests__/renderPlanDriverAuthority.test.ts @@ -128,6 +128,31 @@ test('without a committed readout, the live top-N still feeds the terrain (fallb ); }); +test('certified terrain packet teaches field-first dynamic driver licensing without hard-blocking voice', () => { + const artifact = sealedUranusOnlyArtifact(); + const driver = artifact.data.kind === 'solo_balance_meter' + ? artifact.data.geometry.drivers[0] + : null; + if (driver) { + driver.licenseRole = 'contextual'; + driver.licenseReasons = ['chamber_resonance']; + } + const plan = buildRavenRenderPlan( + transitTelemetryWithThreeDrivers, + session, + configFor('FIELD_REPORT'), + artifact, + ); + const segments = buildDumbPromptSegments(plan, configFor('FIELD_REPORT')); + const packet = segments.map((s) => s.text).join('\n'); + + assert.match(packet, /Field-first directive/i); + assert.match(packet, /Licensed means lawful to use, not mandatory to mention/i); + assert.match(packet, /LICENSED DRIVERS:/); + assert.match(packet, /role=contextual/); + assert.match(packet, /reasons=chamber_resonance/); +}); + test('committed readout feeds the terrain even when no live anchor is present', () => { const plan = buildRavenRenderPlan( null, diff --git a/vessel/src/lib/raven/coherenceDossier.ts b/vessel/src/lib/raven/coherenceDossier.ts index d420b5a4e..511c527d8 100644 --- a/vessel/src/lib/raven/coherenceDossier.ts +++ b/vessel/src/lib/raven/coherenceDossier.ts @@ -18,6 +18,7 @@ * PromptTray is built — it is never persisted. */ +import { claimMayBeNamed } from './geometryArtifact'; import type { ReadoutArtifact, ReadoutArtifactRef, ReadoutPayload, CurrentGeometryArtifact } from './readoutArtifact'; // --------------------------------------------------------------------------- @@ -229,7 +230,7 @@ export function deriveMapStateFromArtifact( const { geometry: geom } = resolved; const bm = geom.balanceMeter; const driverSummary = geom.drivers - .filter((d) => d.isPeakDriver || d.tier !== 'minor') + .filter(claimMayBeNamed) .map((d) => d.label) .filter((label): label is string => Boolean(label)); return { diff --git a/vessel/src/lib/raven/currentGeometryArtifact.ts b/vessel/src/lib/raven/currentGeometryArtifact.ts index a50c65061..592884325 100644 --- a/vessel/src/lib/raven/currentGeometryArtifact.ts +++ b/vessel/src/lib/raven/currentGeometryArtifact.ts @@ -8,6 +8,7 @@ */ import type { MeasurementState } from '../v3MathBrain'; +import type { QuestionCategory } from './categoryRelationshipRouting'; import type { SymbolicMomentPayload } from './symbolicMomentComposer'; import { buildGeometryArtifact, @@ -75,9 +76,12 @@ export type BuildCurrentGeometryArtifactInput = { aspectType?: string; claimId?: string; source?: 'api_verified'; + durationClass?: string; + phaseTone?: string; }>; measurementState?: MeasurementState; priorClaimIds?: readonly string[]; + questionCategory?: QuestionCategory | null; }; /** @@ -98,11 +102,16 @@ export function buildCurrentGeometryArtifact( : payloadOrInput as BuildCurrentGeometryArtifactInput; const { payload, subjectId } = input; + const landingHouses = (payload.landingZones ?? []) + .map((zone) => zone.house) + .filter((house): house is number => typeof house === 'number' && Number.isFinite(house)); const claims = resolveLicensedGeometryClaims({ date: payload.date, anchorTopDrivers: input.anchorTopDrivers, peakDrivers: payload.peakDrivers, measurementState: input.measurementState, + landingHouses, + questionCategory: input.questionCategory, }); const geometryArtifact: GeometryArtifact = buildGeometryArtifact({ diff --git a/vessel/src/lib/raven/geometryArtifact.ts b/vessel/src/lib/raven/geometryArtifact.ts index 80b1b5192..006c608a3 100644 --- a/vessel/src/lib/raven/geometryArtifact.ts +++ b/vessel/src/lib/raven/geometryArtifact.ts @@ -10,6 +10,7 @@ import { isLicensedPlanetToken, type ContactPhase, type MeasurementState } from '../v3MathBrain'; import type { GeometryDriver } from './readoutArtifact'; +import type { QuestionCategory } from './categoryRelationshipRouting'; import type { SymbolicMomentPayload } from './symbolicMomentComposer'; export const GEOMETRY_ARTIFACT_SCHEMA_VERSION = 'geometry-artifact.v1' as const; @@ -22,6 +23,17 @@ export type GeometryClaimSource = | 'user_correction' | 'stale_pinned'; +export type DriverLicenseReason = + | 'mass' + | 'salience' + | 'chamber_resonance' + | 'user_question_resonance' + | 'freshness' + | 'short_window_spike' + | 'sustained_frame'; + +export type DriverLicenseRole = 'primary' | 'contextual' | 'background' | 'excluded'; + export type GeometryClaim = { claimId: string; source: GeometryClaimSource; @@ -38,6 +50,12 @@ export type GeometryClaim = { isPeakDriver: boolean; durationClass?: string; phaseTone?: string; + licenseRole?: DriverLicenseRole; + licenseReasons?: DriverLicenseReason[]; + resonantChambers?: string[]; + mayMention?: boolean; + shouldMention?: boolean; + mustMention?: boolean; }; export type GeometryAuthorityState = 'verified' | 'absent' | 'contradicted'; @@ -122,6 +140,121 @@ type AnchorTopDriver = { phaseTone?: string; }; +const CONTEXTUAL_SALIENCE_THRESHOLD = 0.55; +const QUESTION_SALIENCE_THRESHOLD = 0.35; + +const HOUSE_RESONANT_BODIES: Record = { + 1: ['mars', 'sun'], + 2: ['venus', 'saturn'], + 3: ['mercury', 'uranus'], + 4: ['moon', 'saturn'], + 5: ['sun', 'mars', 'venus'], + 6: ['mercury', 'chiron', 'saturn'], + 7: ['venus', 'moon'], + 8: ['pluto', 'mars'], + 9: ['jupiter'], + 10: ['saturn', 'sun', 'medium_coeli', 'midheaven', 'mc'], + 11: ['uranus', 'jupiter'], + 12: ['neptune', 'moon', 'pluto'], +}; + +const QUESTION_RESONANT_BODIES: Record = { + BUSINESS_CAREER: ['saturn', 'sun', 'medium_coeli', 'midheaven', 'mc', 'mercury'], + MONEY_RESOURCES: ['venus', 'saturn', 'jupiter'], + ROMANCE_INTIMACY: ['venus', 'moon', 'pluto', 'mars'], + COMMUNICATION_MESSAGE: ['mercury', 'uranus'], + FAMILY_KINSHIP: ['moon', 'saturn'], + HEALTH_BODY_LOAD: ['mercury', 'chiron', 'saturn', 'mars'], + CREATIVE_WORK: ['sun', 'mars', 'venus', 'mercury'], + TIMING_DECISION: ['jupiter', 'mercury', 'saturn'], + GENERAL_WEEK_AHEAD: [], + SYNASTRY_COMPATIBILITY: ['venus', 'moon'], + GRIEF_LOSS: ['moon', 'saturn', 'neptune', 'pluto', 'chiron'], +}; + +function unique(values: T[]): T[] { + return Array.from(new Set(values)); +} + +function normalizeBodyToken(value: string | null | undefined): string { + return slug(String(value || '')); +} + +function claimBodyTokens(claim: GeometryClaim): string[] { + return unique([normalizeBodyToken(claim.transitingPlanet), normalizeBodyToken(claim.natalReceiver)].filter(Boolean)); +} + +function claimMatchesAnyBody(claim: GeometryClaim, bodies: readonly string[]): boolean { + if (!bodies.length) return false; + const normalizedBodies = new Set(bodies.map(normalizeBodyToken)); + return claimBodyTokens(claim).some((body) => normalizedBodies.has(body)); +} + +function resolveChamberResonance(claim: GeometryClaim, landingHouses: readonly number[]): string[] { + const resonantHouses = landingHouses + .filter((house) => claimMatchesAnyBody(claim, HOUSE_RESONANT_BODIES[house] ?? [])) + .map((house) => `H${house}`); + return unique(resonantHouses); +} + +type DriverLicenseCandidate = { + mayMention?: boolean; + isPeakDriver?: boolean; + tier?: 'climate' | 'weather' | 'minor'; +}; + +export function claimMayBeNamed(claim: DriverLicenseCandidate): boolean { + if (typeof claim.mayMention === 'boolean') return claim.mayMention; + return Boolean(claim.isPeakDriver) || claim.tier !== 'minor'; +} + +function applyDynamicDriverLicensing(input: { + claims: GeometryClaim[]; + landingHouses?: readonly number[]; + questionCategory?: QuestionCategory | null; +}): GeometryClaim[] { + const landingHouses = input.landingHouses ?? []; + return input.claims.map((claim) => { + const strength = claim.strength ?? 0; + const reasons: DriverLicenseReason[] = []; + const chamberResonance = resolveChamberResonance(claim, landingHouses); + const questionResonant = input.questionCategory + ? claimMatchesAnyBody(claim, QUESTION_RESONANT_BODIES[input.questionCategory] ?? []) + : false; + const somatic = claim.durationClass === 'passing_spike' || normalizeBodyToken(claim.transitingPlanet) === 'moon'; + + if (claim.isPeakDriver) reasons.push('mass'); + if (!claim.isPeakDriver && claim.tier !== 'minor') reasons.push('salience'); + if (chamberResonance.length && strength >= CONTEXTUAL_SALIENCE_THRESHOLD) reasons.push('chamber_resonance'); + if (questionResonant && strength >= QUESTION_SALIENCE_THRESHOLD) reasons.push('user_question_resonance'); + if (claim.durationClass === 'short_window') reasons.push('freshness'); + if (somatic) reasons.push('short_window_spike'); + if (claim.durationClass === 'sustained_frame' || claim.durationClass === 'background_era') reasons.push('sustained_frame'); + + const contextual = reasons.includes('chamber_resonance') || reasons.includes('user_question_resonance'); + const mayMention = claim.isPeakDriver || claim.tier !== 'minor' || contextual || (somatic && strength >= CONTEXTUAL_SALIENCE_THRESHOLD); + const role: DriverLicenseRole = claim.isPeakDriver + ? 'primary' + : !mayMention + ? 'excluded' + : contextual + ? 'contextual' + : claim.durationClass === 'sustained_frame' || claim.durationClass === 'background_era' + ? 'background' + : 'contextual'; + + return { + ...claim, + licenseRole: role, + licenseReasons: unique(reasons.length ? reasons : ['salience']), + ...(chamberResonance.length ? { resonantChambers: chamberResonance } : {}), + mayMention, + shouldMention: role === 'heavy' || contextual, + mustMention: false, + }; + }); +} + const DRIVER_LABEL_ASPECT_PATTERN = /^\s*([a-z]+(?:\s+[a-z]+)?)\s+(square|opposition|conjunction|conjunct|trine|sextile|quincunx|inconjunct|semisquare|semisextile|sesquiquadrate)\s+([a-z]+(?:\s+[a-z]+)?)\s*$/i; @@ -159,7 +292,7 @@ export function buildGeometryClaimsFromDriverLabels(input: { transitingPlanet: slug(transitingPlanet), aspect: slug(aspect), natalReceiver: slug(natalReceiver), - tier: index === 0 ? 'climate' : 'weather', + tier: index === 0 ? 'climate' : index < 5 ? 'weather' : 'minor', isPeakDriver: index < 3, }); } @@ -194,7 +327,7 @@ export function buildGeometryClaimsFromAnchorTopDrivers(input: { ...(typeof driver.orb === 'number' && Number.isFinite(driver.orb) ? { orb: driver.orb } : {}), ...(driver.contactPhase && driver.contactPhase !== 'unknown' ? { phase: driver.contactPhase } : {}), ...(typeof driver.x === 'number' && Number.isFinite(driver.x) ? { strength: driver.x } : {}), - tier: index === 0 ? 'climate' : 'weather', + tier: index === 0 ? 'climate' : index < 5 ? 'weather' : 'minor', isPeakDriver: index < 3, durationClass: driver.durationClass, phaseTone: driver.phaseTone, @@ -247,6 +380,8 @@ export function resolveLicensedGeometryClaims(input: { anchorTopDrivers?: readonly AnchorTopDriver[]; peakDrivers?: SymbolicMomentPayload['peakDrivers']; measurementState?: MeasurementState; + landingHouses?: readonly number[]; + questionCategory?: QuestionCategory | null; }): GeometryClaim[] { if (input.measurementState === 'measurement_degraded') return []; @@ -255,12 +390,18 @@ export function resolveLicensedGeometryClaims(input: { topDrivers: input.anchorTopDrivers ?? [], defaultSource: 'api_verified', }); - if (apiClaims.length > 0) return apiClaims; + const claims = apiClaims.length > 0 + ? apiClaims + : buildGeometryClaimsFromPeakDrivers({ + date: input.date, + peakDrivers: input.peakDrivers ?? [], + source: 'engine_payload', + }); - return buildGeometryClaimsFromPeakDrivers({ - date: input.date, - peakDrivers: input.peakDrivers ?? [], - source: 'engine_payload', + return applyDynamicDriverLicensing({ + claims, + landingHouses: input.landingHouses, + questionCategory: input.questionCategory, }); } @@ -327,6 +468,12 @@ export function geometryDriverFromClaim(claim: GeometryClaim): GeometryDriver { isPeakDriver: claim.isPeakDriver, durationClass: claim.durationClass, phaseTone: claim.phaseTone, + licenseRole: claim.licenseRole, + licenseReasons: claim.licenseReasons, + resonantChambers: claim.resonantChambers, + mayMention: claim.mayMention, + shouldMention: claim.shouldMention, + mustMention: claim.mustMention, }; } @@ -336,6 +483,6 @@ export function geometryDriversFromClaims(claims: readonly GeometryClaim[]): Geo export function licensedDriverLabels(claims: readonly GeometryClaim[]): string[] { return claims - .filter((claim) => claim.isPeakDriver || claim.tier !== 'minor') + .filter(claimMayBeNamed) .map(formatGeometryClaimLabel); } \ No newline at end of file diff --git a/vessel/src/lib/raven/readoutArtifact.ts b/vessel/src/lib/raven/readoutArtifact.ts index 6e6913c85..9558bd849 100644 --- a/vessel/src/lib/raven/readoutArtifact.ts +++ b/vessel/src/lib/raven/readoutArtifact.ts @@ -35,8 +35,11 @@ import { type SubjectDriverAttributionMap, } from './subjectDriverAttribution'; import { + claimMayBeNamed, formatGeometryClaimPromptRow, licensedDriverLabels, + type DriverLicenseReason, + type DriverLicenseRole, type GeometryArtifact, type GeometryClaimSource, } from './geometryArtifact'; @@ -121,6 +124,12 @@ export type GeometryDriver = { texture?: string; durationClass?: string; phaseTone?: string; + licenseRole?: DriverLicenseRole; + licenseReasons?: DriverLicenseReason[]; + resonantChambers?: string[]; + mayMention?: boolean; + shouldMention?: boolean; + mustMention?: boolean; }; export type ChamberLanding = { @@ -1175,13 +1184,19 @@ function serializeReadoutArtifactCore(input: SerializeCoreInput): { text: string } else if (geoArtifact.authority === 'contradicted') { lines.push('GEOMETRY CONTRADICTED: Prior pinned claims were retired. Name only the fresh licensed claim rows below.'); } - const licensedClaims = geoArtifact.claims.filter((claim) => claim.isPeakDriver || claim.tier !== 'minor'); + const licensedClaims = geoArtifact.claims.filter(claimMayBeNamed); if (licensedClaims.length > 0) { lines.push('FRESHNESS DOCTRINE: Do not pretend everything changes every week. Slow bodies (sustained frame, background era) describe the structural frame; they remain stable across reads. Inner bodies (short window) describe the current pressure moving across that frame. The Moon (passing spike) is a somatic day-marker; do not treat it as the reading\'s backbone unless it is exceptionally strong or explicitly requested. When repeating a slow driver, acknowledge it as the continuing frame, and focus freshness on the changed short-window pressure.'); + lines.push('DYNAMIC DRIVER LICENSING: Heavy drivers explain field magnitude. Contextual drivers explain local chamber or question texture. Licensing expands Raven\'s lawful vocabulary; it does not require every licensed driver to be spoken. Begin field-first; use drivers as evidence, not as a menu.'); } for (const claim of licensedClaims) { includedIds.push(claim.claimId); - lines.push(`Licensed claim: ${formatGeometryClaimPromptRow(claim)}.`); + const licenseMeta = [ + claim.licenseRole ? `role=${claim.licenseRole}` : null, + claim.licenseReasons?.length ? `reasons=${claim.licenseReasons.join(',')}` : null, + claim.resonantChambers?.length ? `resonantChambers=${claim.resonantChambers.join(',')}` : null, + ].filter(Boolean).join(' | '); + lines.push(`Licensed claim: ${formatGeometryClaimPromptRow(claim)}${licenseMeta ? ` | ${licenseMeta}` : ''}.`); } const derivedLabels = licensedDriverLabels(geoArtifact.claims); if (derivedLabels.length) { @@ -1270,7 +1285,7 @@ export function buildChamberProvenanceDegradedReadout(artifact: EvidenceArtifact const geometry = artifact.data.geometry; const drivers = geometry.drivers - .filter((driver) => driver.isPeakDriver || driver.tier !== 'minor') + .filter((driver) => claimMayBeNamed(driver)) .map((driver) => driver.label || [driver.transitBody, driver.aspect, driver.natalTarget].filter(Boolean).join(' ')) .map((label) => label.trim()) .filter(Boolean) diff --git a/vessel/src/lib/raven/readoutArtifactClient.ts b/vessel/src/lib/raven/readoutArtifactClient.ts index 328aa726d..bb480e1eb 100644 --- a/vessel/src/lib/raven/readoutArtifactClient.ts +++ b/vessel/src/lib/raven/readoutArtifactClient.ts @@ -11,6 +11,7 @@ * // Stream `wire` via DATA: frame */ +import { claimMayBeNamed } from './geometryArtifact'; import type { ReadoutArtifact, ReadinessArtifact, @@ -337,7 +338,7 @@ export function buildTurnReadoutSummaryFromClient(payload: ReadoutArtifactClient const mag = payload.balanceMeter.magnitude; const bias = payload.balanceMeter.directionalBias; const driverLabels = payload.drivers - .filter((d) => d.isPeakDriver || d.tier !== 'minor') + .filter(claimMayBeNamed) .map((d) => d.label) .filter(Boolean) .slice(0, 3); diff --git a/vessel/src/lib/raven/renderability.ts b/vessel/src/lib/raven/renderability.ts index 4821ed049..ac71264ce 100644 --- a/vessel/src/lib/raven/renderability.ts +++ b/vessel/src/lib/raven/renderability.ts @@ -67,6 +67,12 @@ function driversFromReadoutArtifact( mass: driver.strength ?? 0, polarityBias: 0, activeAspects: [label], + licenseRole: driver.licenseRole, + licenseReasons: driver.licenseReasons, + resonantChambers: driver.resonantChambers, + mayMention: driver.mayMention, + shouldMention: driver.shouldMention, + mustMention: driver.mustMention, }); } for (const landing of geometry.landings) { diff --git a/vessel/src/lib/raven/subjectDriverAttribution.ts b/vessel/src/lib/raven/subjectDriverAttribution.ts index eea6eb286..6e1ae2f06 100644 --- a/vessel/src/lib/raven/subjectDriverAttribution.ts +++ b/vessel/src/lib/raven/subjectDriverAttribution.ts @@ -6,6 +6,7 @@ */ import type { Profile } from '@/app/api/raven-chat/types'; +import { claimMayBeNamed } from './geometryArtifact'; import type { CurrentGeometryArtifact, GeometryDriver, ReadoutArtifact } from './readoutArtifact'; import type { SubjectProfileArtifact } from './subjectProfileArtifact'; @@ -18,7 +19,7 @@ import type { SubjectProfileArtifact } from './subjectProfileArtifact'; * than minor tier. */ export function selectNameableDrivers(geometry: CurrentGeometryArtifact): GeometryDriver[] { - return geometry.drivers.filter((d) => d.isPeakDriver || d.tier !== 'minor'); + return geometry.drivers.filter((driver) => claimMayBeNamed(driver)); } export type SubjectDriverContact = { diff --git a/vessel/src/lib/raven/turnReadoutArtifact.ts b/vessel/src/lib/raven/turnReadoutArtifact.ts index f8f4a210f..15edf2179 100644 --- a/vessel/src/lib/raven/turnReadoutArtifact.ts +++ b/vessel/src/lib/raven/turnReadoutArtifact.ts @@ -26,6 +26,7 @@ import type { HouseFrameState } from '../../app/api/raven-chat/astrologyApi'; import type { Profile, RavenRenderPlan } from '../../app/api/raven-chat/types'; +import type { QuestionCategory } from './categoryRelationshipRouting'; import type { ResolvedLiveLocation } from '../../app/api/raven-chat/types'; import type { ChatGeometryContext } from './chatGeometryContext'; import { getChatCalculationContext } from './chatGeometryContext'; @@ -104,6 +105,7 @@ export type BuildTurnReadoutArtifactInput = { polyadicPerSubjectContexts?: readonly PolyadicPerSubjectTransitContext[]; /** Structural planning and diagnostic terrain generated during the route. */ renderPlan?: RavenRenderPlan; + questionCategory?: QuestionCategory | null; }; const APERTURE_KIND_BY_TEMPORAL: Record = { @@ -158,6 +160,7 @@ export function buildTurnReadoutArtifact( sealedAt: input.generatedAt, anchorTopDrivers: input.astrology?.anchor?.topDrivers, measurementState: input.astrology?.measurementState, + questionCategory: input.questionCategory, }); const calc = getChatCalculationContext(input.astrology) ?? null; diff --git a/vessel/src/lib/v3MathBrain.ts b/vessel/src/lib/v3MathBrain.ts index 7ba83837c..2155f801b 100644 --- a/vessel/src/lib/v3MathBrain.ts +++ b/vessel/src/lib/v3MathBrain.ts @@ -1170,7 +1170,7 @@ export function rankSpeakableDrivers(scored: ScoredAspect[], date: string): Spea const rankedByX = [...scored] .filter((entry) => isLicensedPlanetToken(entry.transitingPlanet) && isLicensedPlanetToken(entry.stationedPlanet)) .sort((a, b) => b.x - a.x); - return rankedByX.slice(0, 5).map((entry) => { + return rankedByX.slice(0, 8).map((entry) => { const phase = entry.contactPhase; const durationClass = resolveDurationClass(entry.transitingPlanet); const phaseTone = resolvePhaseTone(phase, entry.orb, entry.limit); From 94fc1d70bee95b354b26ef5a604478e38eda1bb9 Mon Sep 17 00:00:00 2001 From: DHCross <45954119+DHCross@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:22:31 -0500 Subject: [PATCH 2/2] feat(astrology): add ayanamsa validation and auto-default for Sidereal zodiac calculations Add ayanamsa parameter support with validation for 35 allowed values (1-35). Default to Lahiri (1) for Sidereal zodiac requests when ayanamsa not explicitly provided. Validate ayanamsa at both root and options level, throwing BestAstrologyProxyValidationError for invalid values. Auto-append default ayanamsa for Sidereal zodiac_type to ensure astronomically stable calculations. --- vessel/docs/ASTROLOGY_API_CLIENT.md | 58 +++++++++ .../sherlog-velocity/data/active-session.json | 9 ++ .../__tests__/vaultDuplicateDetection.test.ts | 83 +++++++++++++ vessel/src/lib/bestAstrologyProxy.ts | 72 +++++++++++ vessel/src/lib/vaultDuplicateDetection.ts | 115 ++++++++++++++++++ 5 files changed, 337 insertions(+) create mode 100644 vessel/sherlog-velocity/data/active-session.json create mode 100644 vessel/src/lib/__tests__/vaultDuplicateDetection.test.ts create mode 100644 vessel/src/lib/vaultDuplicateDetection.ts diff --git a/vessel/docs/ASTROLOGY_API_CLIENT.md b/vessel/docs/ASTROLOGY_API_CLIENT.md index fbe73dbde..76665c25b 100644 --- a/vessel/docs/ASTROLOGY_API_CLIENT.md +++ b/vessel/docs/ASTROLOGY_API_CLIENT.md @@ -1,5 +1,63 @@ # Astrology API HTTP Client Guide +## Lens Rotation Doctrine + +In this architecture, **Sidereal is not a separate endpoint**—it is a parameter passed to the same core calculation endpoints. This mirrors the canonical principle: the sky doesn't change, the instrument simply applies a different mathematical offset. + +When calling standard endpoints (like `POST /api/v3/charts/natal` or `POST /api/v3/data/positions`), the URL remains identical. The coder injects a configuration flag—specifically an **ayanamsa** value (the mathematical offset between the Tropical and Sidereal zodiacs)—into the request body. + +### Why This Matters + +If Sidereal were a completely different endpoint, we would risk maintaining two separate calculation pipelines, introducing mathematical drift. By using the same endpoint and toggling the `ayanamsa` parameter, the system looks at the exact same Swiss Ephemeris data, just through a different lens. + +### Payload Examples + +**Tropical Payload (The Weather):** +```json +{ + "year": 2026, + "month": 6, + "day": 28, + "hour": 18, + "minute": 5, + "latitude": 30.24, + "longitude": -85.64, + "house_system": "P", + "zodiac_type": "tropical" +} +``` + +**Sidereal Payload (The Bones):** +```json +{ + "year": 2026, + "month": 6, + "day": 28, + "hour": 18, + "minute": 5, + "latitude": 30.24, + "longitude": -85.64, + "house_system": "P", + "zodiac_type": "sidereal", + "ayanamsa": 1 +} +``` + +**Critical:** When `zodiac_type` is set to `"sidereal"`, the `ayanamsa` parameter **must** be explicitly defined. The proxy automatically defaults to `ayanamsa: 1` (Lahiri/Chitra Paksha) if not specified, as this is the most astronomically stable and widely accepted offset. Arbitrary offsets are prohibited—deterministic geometry is required. + +### Supported Ayanamsa Values + +| Value | Name | Tradition | +|-------|------|-----------| +| 1 | Lahiri (Chitra Paksha) | Indian/Vedic (default) | +| 2 | Raman | Indian/Vedic | +| 3 | Fagan-Bradley | Western | +| 4 | Krishnamurti | Indian/Vedic | +| 5 | Ushashashi | Indian/Vedic | +| 6-35 | Various | Indian, Western, Babylonian, Other | + +See the upstream API documentation for the complete list of 35 supported ayanamsa values. + ## Recommended HTTP Client: `fetch` For the Astrology API integration in this project, we recommend using the **native `fetch` API**. This is the standard approach already established throughout the Shipyard codebase. diff --git a/vessel/sherlog-velocity/data/active-session.json b/vessel/sherlog-velocity/data/active-session.json new file mode 100644 index 000000000..6438bbb11 --- /dev/null +++ b/vessel/sherlog-velocity/data/active-session.json @@ -0,0 +1,9 @@ +{ + "feature": "Raven Dynamic Driver Licensing", + "type": "implementation", + "startTime": "2026-06-28T22:59:29.332Z", + "notes": [], + "branch": "main", + "startHead": "5346f4769f19ef67bb470ec6822406660d926c03", + "cwd": "." +} \ No newline at end of file diff --git a/vessel/src/lib/__tests__/vaultDuplicateDetection.test.ts b/vessel/src/lib/__tests__/vaultDuplicateDetection.test.ts new file mode 100644 index 000000000..14ee89bce --- /dev/null +++ b/vessel/src/lib/__tests__/vaultDuplicateDetection.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { findDuplicateProfileClusters, countDuplicateProfiles } from '../vaultDuplicateDetection'; +import type { VaultProfile } from '../vaultSync'; + +function makeProfile(overrides: Partial = {}): VaultProfile { + return { + id: `profile_${Math.random().toString(36).slice(2, 8)}`, + isPrimary: false, + name: 'Daniel Cross', + notes: null, + natalChart: null, + cognitiveArchitecture: null, + cognitiveArchitectureProvenance: null, + relationshipContext: null, + constitutionalCalibrations: null, + birthData: { + year: 1973, + month: 7, + day: 24, + hour: 14, + minute: 30, + latitude: 40.023, + longitude: -75.315, + timezone: 'America/New_York', + city: 'Bryn Mawr', + state: 'Pennsylvania', + country_code: 'US', + }, + birthTimeRecord: null, + provenance: 'CLIENT_SELF', + currentLocation: null, + lastUpdated: new Date().toISOString(), + ...overrides, + } as VaultProfile; +} + +test('findDuplicateProfileClusters returns empty for single profile', () => { + const clusters = findDuplicateProfileClusters([makeProfile()]); + assert.deepEqual(clusters, []); +}); + +test('findDuplicateProfileClusters detects exact duplicates', () => { + const a = makeProfile({ id: 'a', isPrimary: true }); + const b = makeProfile({ id: 'b' }); + const clusters = findDuplicateProfileClusters([a, b]); + assert.equal(clusters.length, 1); + assert.equal(clusters[0].reason, 'exact'); + assert.equal(clusters[0].canonicalId, 'a'); + assert.equal(clusters[0].duplicates.length, 1); + assert.equal(clusters[0].duplicates[0].id, 'b'); +}); + +test('findDuplicateProfileClusters detects near duplicates when birth time differs', () => { + const a = makeProfile({ id: 'a', isPrimary: true }); + const b = makeProfile({ id: 'b', birthData: { ...a.birthData, hour: 15, minute: 0 } }); + const clusters = findDuplicateProfileClusters([a, b]); + assert.equal(clusters.length, 1); + assert.equal(clusters[0].reason, 'near_match_name_birth'); +}); + +test('findDuplicateProfileClusters ignores profiles with different names', () => { + const a = makeProfile({ id: 'a', name: 'Daniel Cross' }); + const b = makeProfile({ id: 'b', name: 'Sarah Cross' }); + const clusters = findDuplicateProfileClusters([a, b]); + assert.equal(clusters.length, 0); +}); + +test('findDuplicateProfileClusters ignores profiles with different birth dates', () => { + const a = makeProfile({ id: 'a' }); + const b = makeProfile({ id: 'b', birthData: { ...a.birthData, day: 25 } }); + const clusters = findDuplicateProfileClusters([a, b]); + assert.equal(clusters.length, 0); +}); + +test('countDuplicateProfiles returns total duplicate count across clusters', () => { + const a = makeProfile({ id: 'a', name: 'Daniel Cross' }); + const b = makeProfile({ id: 'b', name: 'Daniel Cross' }); + const c = makeProfile({ id: 'c', name: 'Sarah Cross' }); + const d = makeProfile({ id: 'd', name: 'Sarah Cross' }); + const count = countDuplicateProfiles([a, b, c, d]); + assert.equal(count, 2); +}); diff --git a/vessel/src/lib/bestAstrologyProxy.ts b/vessel/src/lib/bestAstrologyProxy.ts index 6001303df..398df5c1a 100644 --- a/vessel/src/lib/bestAstrologyProxy.ts +++ b/vessel/src/lib/bestAstrologyProxy.ts @@ -79,6 +79,49 @@ const ZODIAC_TYPE_MAP: Record = { sidereal: 'Sidereal', }; +/** + * Ayanamsa values for Sidereal zodiac calculations. + * Default is 1 (Lahiri/Chitra Paksha), the most astronomically stable and widely accepted offset. + */ +const DEFAULT_AYANAMSA = 1; +const ALLOWED_AYANAMSA_VALUES = new Set([ + 1, // Lahiri (Chitra Paksha) - default + 2, // Raman + 3, // Fagan-Bradley + 4, // Krishnamurti + 5, // Ushashashi + 6, // Lahiri (1940) + 7, // Lahiri (ICRC) + 8, // Lahiri (VP285) + 9, // Krishnamurti (VP291) + 10, // JN Bhasin + 11, // Yukteshwar + 12, // Aryabhata + 13, // Aryabhata (522) + 14, // Aryabhata (Msun) + 15, // Suryasiddhanta + 16, // Suryasiddhanta (Msun) + 17, // SS Citra + 18, // SS Revati + 19, // True Citra + 20, // True Mula + 21, // True Pushya + 22, // True Revati + 23, // True Sheoran + 24, // Deluce + 25, // Djwhal Khul + 26, // Hipparchos + 27, // Sassanian + 28, // Babyl Kugler1 + 29, // Babyl Kugler2 + 30, // Babyl Kugler3 + 31, // Babyl Huber + 32, // Babyl ETPSC + 33, // Babyl Britton + 34, // Aldebaran 15 Tau + 35, // Valens Moon +]); + function normalizeAstrologyPayload(payload: unknown): unknown { if (!isRecord(payload)) return payload; @@ -94,6 +137,15 @@ function normalizeAstrologyPayload(payload: unknown): unknown { if (mapped) result.zodiac_type = mapped; } + // Validate and normalize ayanamsa for Sidereal calculations + if (typeof result.ayanamsa === 'number') { + if (!ALLOWED_AYANAMSA_VALUES.has(result.ayanamsa)) { + throw new BestAstrologyProxyValidationError( + `Invalid ayanamsa value "${result.ayanamsa}". Supported values: 1-35 (1=Lahiri, 2=Raman, 3=Fagan-Bradley, etc.).` + ); + } + } + if (isRecord(result.options)) { const options: Record = { ...result.options }; @@ -107,9 +159,29 @@ function normalizeAstrologyPayload(payload: unknown): unknown { if (mapped) options.zodiac_type = mapped; } + // Validate and normalize ayanamsa in options + if (typeof options.ayanamsa === 'number') { + if (!ALLOWED_AYANAMSA_VALUES.has(options.ayanamsa)) { + throw new BestAstrologyProxyValidationError( + `Invalid ayanamsa value "${options.ayanamsa}". Supported values: 1-35 (1=Lahiri, 2=Raman, 3=Fagan-Bradley, etc.).` + ); + } + } + + // Auto-append ayanamsa for Sidereal zodiac type in options + const zodiacType = typeof options.zodiac_type === 'string' ? options.zodiac_type : typeof result.zodiac_type === 'string' ? result.zodiac_type : null; + if (zodiacType === 'Sidereal' && typeof options.ayanamsa !== 'number') { + options.ayanamsa = DEFAULT_AYANAMSA; + } + result.options = options; } + // Auto-append ayanamsa for Sidereal zodiac type at root level + if (typeof result.zodiac_type === 'string' && result.zodiac_type === 'Sidereal' && typeof result.ayanamsa !== 'number' && !isRecord(result.options)) { + result.ayanamsa = DEFAULT_AYANAMSA; + } + // Validate progression_type - only "secondary" is supported if (typeof result.progression_type === 'string') { const allowed = new Set(['secondary']); diff --git a/vessel/src/lib/vaultDuplicateDetection.ts b/vessel/src/lib/vaultDuplicateDetection.ts new file mode 100644 index 000000000..fe71a4ef6 --- /dev/null +++ b/vessel/src/lib/vaultDuplicateDetection.ts @@ -0,0 +1,115 @@ +import type { VaultProfile } from './vaultSync'; + +export type DuplicateReason = 'exact' | 'near_match_name_birth'; + +export type DuplicateCluster = { + canonicalId: string; + duplicates: VaultProfile[]; + reason: DuplicateReason; +}; + +function normalizeName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function formatDate(year: number, month: number, day: number): string { + return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +function formatTime(hour: number, minute: number): string { + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +function exactProfileKey(profile: VaultProfile): string { + const birth = profile.birthData; + const city = (birth.city || '').toLowerCase().trim(); + const state = (birth.state || '').toLowerCase().trim(); + return [ + normalizeName(profile.name), + formatDate(birth.year, birth.month, birth.day), + formatTime(birth.hour, birth.minute), + city, + state, + ].join('|'); +} + +function nearMatchKey(profile: VaultProfile): string { + const birth = profile.birthData; + return [ + normalizeName(profile.name), + formatDate(birth.year, birth.month, birth.day), + ].join('|'); +} + +function pickCanonical(profiles: VaultProfile[]): VaultProfile { + // Prefer primary, then most recently updated, then the one with the most fields populated. + const sorted = [...profiles].sort((a, b) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + const aUpdated = a.lastUpdated || ''; + const bUpdated = b.lastUpdated || ''; + if (aUpdated !== bUpdated) return bUpdated.localeCompare(aUpdated); + const aFields = Object.values(a).filter((v) => v !== null && v !== undefined).length; + const bFields = Object.values(b).filter((v) => v !== null && v !== undefined).length; + return bFields - aFields; + }); + return sorted[0]; +} + +export function findDuplicateProfileClusters(profiles: VaultProfile[]): DuplicateCluster[] { + if (!Array.isArray(profiles) || profiles.length < 2) return []; + + const clusters: DuplicateCluster[] = []; + const seenExactKeys = new Map(); + const seenNearKeys = new Map(); + + for (const profile of profiles) { + const exactKey = exactProfileKey(profile); + const nearKey = nearMatchKey(profile); + if (!seenExactKeys.has(exactKey)) seenExactKeys.set(exactKey, []); + if (!seenNearKeys.has(nearKey)) seenNearKeys.set(nearKey, []); + seenExactKeys.get(exactKey)!.push(profile); + seenNearKeys.get(nearKey)!.push(profile); + } + + const exactClusterIds = new Set(); + for (const group of seenExactKeys.values()) { + if (group.length < 2) continue; + const canonical = pickCanonical(group); + const duplicates = group.filter((profile) => profile.id !== canonical.id); + if (duplicates.length === 0) continue; + const clusterId = [canonical.id, ...duplicates.map((p) => p.id)].sort().join(','); + if (exactClusterIds.has(clusterId)) continue; + exactClusterIds.add(clusterId); + clusters.push({ + canonicalId: canonical.id, + duplicates, + reason: 'exact', + }); + } + + const nearClusterIds = new Set(); + for (const group of seenNearKeys.values()) { + if (group.length < 2) continue; + const canonical = pickCanonical(group); + const duplicates = group.filter((profile) => profile.id !== canonical.id); + if (duplicates.length === 0) continue; + const clusterId = [canonical.id, ...duplicates.map((p) => p.id)].sort().join(','); + if (exactClusterIds.has(clusterId) || nearClusterIds.has(clusterId)) continue; + nearClusterIds.add(clusterId); + clusters.push({ + canonicalId: canonical.id, + duplicates, + reason: 'near_match_name_birth', + }); + } + + return clusters; +} + +export function countDuplicateProfiles(profiles: VaultProfile[]): number { + return findDuplicateProfileClusters(profiles).reduce((sum, cluster) => sum + cluster.duplicates.length, 0); +}