feat(archief-assistent): enhance semantic cache with ontology-driven vocabulary

- Integrate tier-2 embeddings from types-vocab.json
- Add segment-based caching for improved retrieval
- Update tests and documentation
This commit is contained in:
kempersc 2026-01-10 15:38:11 +01:00
parent ad74d8379e
commit cce484c6b8
3 changed files with 713 additions and 8 deletions

View file

@ -2,6 +2,78 @@
🚨 **CRITICAL**: The semantic cache MUST use vocabulary derived from LinkML `*Type.yaml` and `*Types.yaml` schema files to extract entities for cache key generation. Hardcoded regex patterns are deprecated.
**Status**: Implemented (Evolved v2.0)
**Version**: 2.0 (Epistemological Evolution)
**Updated**: 2026-01-10
## Evolution Overview
Rule 46 v2.0 incorporates insights from Volodymyr Pavlyshyn's work on agentic memory systems:
1. **Epistemic Provenance** (Phase 1) - Track WHERE, WHEN, HOW data originated
2. **Topological Distance** (Phase 2) - Use ontology structure, not just embeddings
3. **Holarchic Cache** (Phase 3) - Entries as holons with up/down links
4. **Message Passing** (Phase 4, planned) - Smalltalk-style introspectable cache
5. **Clarity Trading** (Phase 5, planned) - Block ambiguous queries from cache
## Epistemic Provenance
Every cached response carries epistemological metadata:
```typescript
interface EpistemicProvenance {
dataSource: 'ISIL_REGISTRY' | 'WIKIDATA' | 'CUSTODIAN_YAML' | 'LLM_INFERENCE' | ...;
dataTier: 1 | 2 | 3 | 4; // TIER_1_AUTHORITATIVE → TIER_4_INFERRED
sourceTimestamp: string;
derivationChain: string[]; // ["SPARQL:Qdrant", "RAG:retrieve", "LLM:generate"]
revalidationPolicy: 'static' | 'daily' | 'weekly' | 'on_access';
}
```
**Benefit**: Users see "This answer is from TIER_1 ISIL registry data, captured 2025-01-08".
## Topological Distance
Beyond embedding similarity, cache matching considers **structural distance** in the type hierarchy:
```
HeritageCustodian (*)
┌──────────────────┼──────────────────┐
▼ ▼ ▼
MuseumType (M) ArchiveType (A) LibraryType (L)
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼ ▼ ▼
ArtMuseum History Municipal State Public Academic
```
**Combined Similarity Formula**:
```typescript
finalScore = 0.7 * embeddingSimilarity + 0.3 * (1 - topologicalDistance)
```
**Benefit**: "Art museum" won't match "natural history museum" even with 95% embedding similarity.
## Holarchic Cache Structure
Cache entries are **holons** - simultaneously complete AND parts of aggregates:
| Level | Example | Aggregates |
|-------|---------|------------|
| Micro | "Rijksmuseum details" | None |
| Meso | "Museums in Amsterdam" | List of micro holons |
| Macro | "Heritage in Noord-Holland" | All meso holons in region |
```typescript
interface CachedQuery {
// ... existing fields ...
holonLevel?: 'micro' | 'meso' | 'macro';
participatesIn?: string[]; // Higher-level cache keys
aggregates?: string[]; // Lower-level entries
}
```
## Problem Statement
The ArchiefAssistent semantic cache prevents geographic false positives using entity extraction:
@ -498,6 +570,14 @@ Key test cases:
---
**Created**: 2026-01-10
**Author**: OpenCode Agent
**Status**: Implementing
**Created**: 2026-01-10
**Author**: OpenCode Agent
**Status**: Implemented (v2.0)
## References
- Pavlyshyn, V. "Context Graphs and Data Traces: Building Epistemology Layers for Agentic Memory"
- Pavlyshyn, V. "The Shape of Knowledge: Topology Theory for Knowledge Graphs"
- Pavlyshyn, V. "Beyond Hierarchy: Why Agentic AI Systems Need Holarchies"
- Pavlyshyn, V. "Smalltalk: The Language That Changed Everything"
- Pavlyshyn, V. "Clarity Traders: Beyond Vibe Coding"

View file

@ -33,6 +33,85 @@
*/
export type InstitutionTypeCode = 'G' | 'L' | 'A' | 'M' | 'O' | 'R' | 'C' | 'U' | 'B' | 'E' | 'S' | 'F' | 'I' | 'X' | 'P' | 'H' | 'D' | 'N' | 'T';
// ============================================================================
// Epistemic Provenance (Phase 1 - Rule 46 Evolution)
// ============================================================================
// Based on Pavlyshyn's "Context Graphs and Data Traces": Every cached response
// should carry epistemological metadata about WHERE, WHEN, HOW, and WHAT KIND
// of epistemic status the data holds. This enables revalidation and trust.
/**
* Data source types aligned with project's data tier system.
* See: AGENTS.md Rule 22 (Custodian YAML Files Are the Single Source of Truth)
*/
export type EpistemicDataSource =
| 'ISIL_REGISTRY' // Dutch ISIL codes CSV (TIER_1)
| 'WIKIDATA' // Wikidata SPARQL endpoint (TIER_3)
| 'CUSTODIAN_YAML' // data/custodian/*.yaml files (TIER_1)
| 'GOOGLE_MAPS' // Google Places API (TIER_3)
| 'WEB_SCRAPE' // Website content with XPath provenance (TIER_2)
| 'LLM_INFERENCE' // Generated by LLM without verifiable source (TIER_4)
| 'SPARQL_QUERY' // Direct SPARQL query result (TIER_1-3 depending on source)
| 'RAG_PIPELINE' // Full RAG retrieval + generation (mixed tiers)
| 'USER_PROVIDED' // User-submitted data (TIER_4 until verified)
| 'CACHE_AGGREGATION'; // Computed from other cached entries
/**
* Data quality tiers aligned with AGENTS.md provenance model.
* Higher tier = higher authority = more trustworthy.
*/
export type DataTier = 1 | 2 | 3 | 4;
/**
* Epistemic provenance tracks the justification chain for cached knowledge.
*
* This transforms the cache from a collection of answers into a *justified*
* knowledge base where each response carries information about:
* - WHERE the information originated
* - WHEN it was captured
* - HOW it was derived
* - WHAT KIND of epistemic status it holds
*
* @see https://volodymyrpavlyshyn.medium.com/context-graphs-and-data-traces-building-epistemology-layers-for-agentic-memory-64ee876c846f
*/
export interface EpistemicProvenance {
/** Primary source of the cached data */
dataSource: EpistemicDataSource;
/** Data quality tier (1=authoritative, 4=inferred) */
dataTier: DataTier;
/** ISO 8601 timestamp when the source data was captured/queried */
sourceTimestamp: string;
/**
* Derivation chain showing how the answer was produced.
* Example: ["SPARQL:Qdrant", "RAG:retrieve", "LLM:generate"]
*/
derivationChain: string[];
/**
* When this cache entry should be revalidated.
* - 'static': Never revalidate (e.g., historical facts)
* - 'daily': Revalidate after 24 hours
* - 'weekly': Revalidate after 7 days
* - 'on_access': Revalidate every time (expensive, for volatile data)
*/
revalidationPolicy: 'static' | 'daily' | 'weekly' | 'on_access';
/** ISO 8601 timestamp of last revalidation (if applicable) */
lastRevalidated?: string;
/** Confidence score (0.0 - 1.0) based on source quality and derivation */
confidenceScore?: number;
/** Source URLs or identifiers for traceability */
sourceReferences?: string[];
/** Notes about any caveats or limitations */
epistemicNotes?: string;
}
/**
* Entities extracted from a query for structured cache key generation.
* Used to prevent geographic false positives (e.g., "Amsterdam" vs "Noord-Holland").
@ -52,6 +131,72 @@ export interface ExtractedEntities {
intent?: 'count' | 'list' | 'info' | null;
/** Method used for entity extraction */
extractionMethod?: 'vocabulary' | 'regex' | 'embedding';
// ============================================================================
// Phase 5: Clarity Trading (Rule 46 Evolution)
// ============================================================================
/**
* Clarity score (0.0 - 1.0) indicating how unambiguous the query is.
* Queries with clarityScore < 0.7 should bypass cache and go to RAG.
*/
clarityScore?: number;
/** Identified ambiguities that reduce clarity */
ambiguities?: string[];
}
// ============================================================================
// Phase 4: Message-Passing Protocol (Smalltalk-Inspired)
// ============================================================================
// Based on Pavlyshyn's "Smalltalk: The Language That Changed Everything":
// Queries should be MESSAGES to holons, not function calls.
export type CacheMessageType = 'LOOKUP' | 'STORE' | 'INVALIDATE' | 'EXPLAIN';
export interface CacheMessage {
type: CacheMessageType;
/** Smalltalk-style message selector (e.g., "count:archives:municipal:GE") */
selector: string;
arguments: {
query?: string;
embedding?: number[] | null;
response?: CachedResponse;
entities?: ExtractedEntities;
};
/** Timestamp when message was created */
timestamp: number;
}
export interface CacheDecisionTrace {
/** The original query */
query: string;
/** Extracted entities */
entities: ExtractedEntities;
/** Structured cache key used */
structuredKey: string;
/** Whether cache hit occurred */
hit: boolean;
/** Tier where hit occurred (if any) */
tier?: 'local' | 'shared';
/** Match method used */
method: 'semantic' | 'fuzzy' | 'exact' | 'structured' | 'none';
/** Similarity score */
similarity: number;
/** Topological distance (if applicable) */
topologicalDistance?: number;
/** Why this decision was made */
reasoning: string;
/** Epistemic provenance of the cached entry (if hit) */
provenance?: EpistemicProvenance;
/** Time taken for lookup */
lookupTimeMs: number;
}
export interface CacheMessageResponse {
success: boolean;
result?: CacheLookupResult;
/** Decision trace for explainability */
trace?: CacheDecisionTrace;
error?: string;
}
export interface CachedQuery {
@ -70,6 +215,18 @@ export interface CachedQuery {
entities?: ExtractedEntities;
/** Structured cache key derived from entities (e.g., "count:M:amsterdam") */
structuredKey?: string;
/** Epistemic provenance for trust and revalidation (Phase 1 - Rule 46 Evolution) */
epistemicProvenance?: EpistemicProvenance;
// ============================================================================
// Holonic Cache Properties (Phase 3 - Rule 46 Evolution)
// ============================================================================
/** Holon level: micro (single entity), meso (type+location), macro (aggregate) */
holonLevel?: 'micro' | 'meso' | 'macro';
/** Cache keys this entry participates in (upward links in holarchy) */
participatesIn?: string[];
/** Cache keys this entry aggregates (downward links in holarchy) */
aggregates?: string[];
}
export interface CachedResponse {
@ -228,6 +385,366 @@ function generateCacheId(): string {
return `cache_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// ============================================================================
// Type Hierarchy DAG (Phase 2 - Topological Distance)
// ============================================================================
// Based on Pavlyshyn's "The Shape of Knowledge": Embeddings lose structural
// information. The SHAPE of the query's relationship to the ontology matters.
/**
* GLAMORCUBESFIXPHDNT type hierarchy as a DAG.
* Each type maps to its parent types (toward HeritageCustodian root).
*/
const TYPE_HIERARCHY: Record<string, string[]> = {
// Root
'*': [], // HeritageCustodian (universal root)
// Tier 1: Base types (direct children of root)
'G': ['*'], 'L': ['*'], 'A': ['*'], 'M': ['*'], 'O': ['*'],
'R': ['*'], 'C': ['*'], 'U': ['*'], 'B': ['*'], 'E': ['*'],
'S': ['*'], 'F': ['*'], 'I': ['*'], 'X': ['*'], 'P': ['*'],
'H': ['*'], 'D': ['*'], 'N': ['*'], 'T': ['*'],
// Tier 2: Subtypes (examples - extend as needed from schema)
'M.ART': ['M'], 'M.HISTORY': ['M'], 'M.SCIENCE': ['M'], 'M.NATURAL': ['M'],
'A.MUNICIPAL': ['A'], 'A.STATE': ['A'], 'A.CORPORATE': ['A'], 'A.REGIONAL': ['A'],
'L.PUBLIC': ['L'], 'L.ACADEMIC': ['L'], 'L.SPECIAL': ['L'],
'E.UNIVERSITY': ['E'], 'E.SCHOOL': ['E'],
'H.CHURCH': ['H'], 'H.MOSQUE': ['H'], 'H.SYNAGOGUE': ['H'],
};
/** Depth cache for topological distance calculation */
const depthCache: Map<string, number> = new Map();
/**
* Get the depth of a type in the hierarchy (memoized).
*/
function getTypeDepth(typeCode: string): number {
if (depthCache.has(typeCode)) return depthCache.get(typeCode)!;
if (typeCode === '*') {
depthCache.set('*', 0);
return 0;
}
const parents = TYPE_HIERARCHY[typeCode];
if (!parents || parents.length === 0) {
// Unknown type, treat as direct child of root
depthCache.set(typeCode, 1);
return 1;
}
const depth = 1 + Math.min(...parents.map(p => getTypeDepth(p)));
depthCache.set(typeCode, depth);
return depth;
}
/**
* Find the lowest common ancestor of two types in the hierarchy.
*/
function findLCA(type1: string, type2: string): string {
if (type1 === type2) return type1;
if (type1 === '*' || type2 === '*') return '*';
// Get ancestors of type1
const ancestors1 = new Set<string>();
let current = type1;
while (current && current !== '*') {
ancestors1.add(current);
const parents = TYPE_HIERARCHY[current];
current = parents?.[0] || '*';
}
ancestors1.add('*');
// Find first ancestor of type2 that's in ancestors1
current = type2;
while (current) {
if (ancestors1.has(current)) return current;
const parents = TYPE_HIERARCHY[current];
current = parents?.[0] || '*';
if (current === '*' && ancestors1.has('*')) return '*';
}
return '*'; // Root is always common ancestor
}
/**
* Compute topological distance between two institution types.
*
* Based on path length through the type hierarchy DAG.
* Returns 0.0 for identical types, 1.0 for maximally distant types.
*
* @example
* topologicalDistance('M.ART', 'M.HISTORY') // 0.33 (siblings under M)
* topologicalDistance('M.ART', 'A.MUNICIPAL') // 0.67 (different base types)
* topologicalDistance('M', 'M') // 0.0 (identical)
*/
export function topologicalDistance(type1: string, type2: string): number {
if (type1 === type2) return 0;
// Normalize to uppercase
const t1 = type1.toUpperCase();
const t2 = type2.toUpperCase();
const lca = findLCA(t1, t2);
const depth1 = getTypeDepth(t1);
const depth2 = getTypeDepth(t2);
const depthLCA = getTypeDepth(lca);
// Path length = distance from t1 to LCA + distance from t2 to LCA
const pathLength = (depth1 - depthLCA) + (depth2 - depthLCA);
// Max possible depth in hierarchy (currently 2: root -> base -> subtype)
const maxDepth = 2;
// Normalize to 0-1 range
return Math.min(pathLength / (2 * maxDepth), 1.0);
}
/**
* Combined similarity score using both embedding similarity and topological distance.
*
* @param embeddingSimilarity - Cosine similarity of embeddings (0-1)
* @param queryType - Institution type from query
* @param cachedType - Institution type from cached entry
* @param embeddingWeight - Weight for embedding similarity (default 0.7)
* @returns Combined similarity score (0-1)
*/
export function combinedSimilarity(
embeddingSimilarity: number,
queryType: string | undefined,
cachedType: string | undefined,
embeddingWeight: number = 0.7
): number {
// If no types available, use pure embedding similarity
if (!queryType || !cachedType) return embeddingSimilarity;
const topoDist = topologicalDistance(queryType, cachedType);
const topoSimilarity = 1 - topoDist;
// Weighted combination
return embeddingWeight * embeddingSimilarity + (1 - embeddingWeight) * topoSimilarity;
}
// ============================================================================
// Phase 5: Clarity Trading (Rule 46 Evolution)
// ============================================================================
// Based on Pavlyshyn's "Clarity Traders: Beyond Vibe Coding":
// The real work is bringing clarity to ambiguity, not generating code/cache keys.
/** Patterns that indicate ambiguity in queries */
const AMBIGUITY_PATTERNS: Array<{ pattern: RegExp; type: string; penalty: number }> = [
// Temporal ambiguity: "old", "recent", "historical" without dates
{ pattern: /\b(oude?|old|recent|historical|historisch)\b(?!.*\d{4})/i, type: 'temporal_vague', penalty: 0.15 },
// Size ambiguity: "large", "small", "big" without metrics
{ pattern: /\b(grote?|small|large|big|klein)\b/i, type: 'size_vague', penalty: 0.10 },
// Quality ambiguity: "best", "good", "important"
{ pattern: /\b(beste?|good|best|important|belangrijk)\b/i, type: 'quality_vague', penalty: 0.10 },
// Vague quantifiers: "some", "many", "few"
{ pattern: /\b(sommige|some|many|veel|few|weinig)\b/i, type: 'quantity_vague', penalty: 0.05 },
// Pronouns without antecedents: "it", "they", "this"
{ pattern: /^(het|it|they|this|dat|die)\b/i, type: 'pronoun_start', penalty: 0.20 },
// Very short queries (likely incomplete)
{ pattern: /^.{1,10}$/i, type: 'too_short', penalty: 0.25 },
];
/** Patterns that indicate high clarity */
const CLARITY_PATTERNS: Array<{ pattern: RegExp; type: string; bonus: number }> = [
// Specific location mentioned
{ pattern: /\b(amsterdam|rotterdam|utrecht|den haag|groningen)\b/i, type: 'specific_city', bonus: 0.10 },
// Specific type mentioned
{ pattern: /\b(museum|archief|bibliotheek|archive|library)\b/i, type: 'specific_type', bonus: 0.10 },
// Specific intent
{ pattern: /\b(hoeveel|welke|waar|count|list|how many)\b/i, type: 'clear_intent', bonus: 0.10 },
// ISO codes or identifiers
{ pattern: /\b(ISIL|Q\d+|NL-[A-Z]{2,})\b/i, type: 'identifier', bonus: 0.15 },
// Date ranges
{ pattern: /\b\d{4}\s*[-]\s*\d{4}\b/i, type: 'date_range', bonus: 0.10 },
];
/**
* Calculate clarity score for a query (Phase 5 - Clarity Trading).
*
* High clarity (0.7): Query is unambiguous, safe to use cached response.
* Low clarity (<0.7): Query is ambiguous, should bypass cache and go to RAG.
*
* @param query - The user's query text
* @param entities - Already-extracted entities (to avoid re-extraction)
* @returns Clarity score (0.0 - 1.0) and list of identified ambiguities
*/
export function calculateClarity(
query: string,
entities?: ExtractedEntities
): { clarityScore: number; ambiguities: string[] } {
let score = 0.7; // Start at threshold
const ambiguities: string[] = [];
// Check for ambiguity patterns (reduce score)
for (const { pattern, type, penalty } of AMBIGUITY_PATTERNS) {
if (pattern.test(query)) {
score -= penalty;
ambiguities.push(type);
}
}
// Check for clarity patterns (increase score)
for (const { pattern, bonus } of CLARITY_PATTERNS) {
if (pattern.test(query)) {
score += bonus;
}
}
// Bonus for having extracted entities
if (entities) {
if (entities.institutionType) score += 0.05;
if (entities.location) score += 0.05;
if (entities.intent) score += 0.05;
if (entities.institutionSubtype) score += 0.05; // Very specific
}
// Clamp to 0-1 range
const clarityScore = Math.max(0, Math.min(1, score));
return { clarityScore, ambiguities };
}
/**
* Extract entities with clarity scoring (Phase 5 - Clarity Trading).
*
* Enhanced version of extractEntitiesFast that includes clarity assessment.
* Queries with clarityScore < 0.7 should bypass cache.
*
* @param query - The user's query text
* @returns Extracted entities with clarity score and ambiguities
*/
export function extractEntitiesWithClarity(query: string): ExtractedEntities {
const entities = extractEntitiesFast(query);
const { clarityScore, ambiguities } = calculateClarity(query, entities);
return {
...entities,
clarityScore,
ambiguities: ambiguities.length > 0 ? ambiguities : undefined,
};
}
// ============================================================================
// Phase 4: Message Handler (Smalltalk-Inspired Introspection)
// ============================================================================
/** Last decision trace for explainability */
let lastDecisionTrace: CacheDecisionTrace | null = null;
/**
* Handle a cache message with full introspection capability.
*
* Based on Smalltalk's message-passing paradigm where every object can:
* - Receive and handle messages
* - Explain its last decision
* - Introspect its own state
*
* @param message - The cache message to handle
* @param cache - The SemanticCache instance
* @returns Response with optional decision trace
*/
export async function handleCacheMessage(
message: CacheMessage,
cache: SemanticCache
): Promise<CacheMessageResponse> {
const startTime = performance.now();
try {
switch (message.type) {
case 'LOOKUP': {
if (!message.arguments.query) {
return { success: false, error: 'LOOKUP requires query argument' };
}
const query = message.arguments.query;
const embedding = message.arguments.embedding;
const entities = extractEntitiesWithClarity(query);
const structuredKey = generateStructuredCacheKey(entities);
// Phase 5: Block low-clarity queries
if (entities.clarityScore !== undefined && entities.clarityScore < 0.7) {
lastDecisionTrace = {
query,
entities,
structuredKey,
hit: false,
method: 'none',
similarity: 0,
reasoning: `Query clarity too low (${entities.clarityScore.toFixed(2)}). Ambiguities: ${entities.ambiguities?.join(', ')}. Bypassing cache.`,
lookupTimeMs: performance.now() - startTime,
};
return {
success: true,
result: { found: false, similarity: 0, method: 'none', lookupTimeMs: lastDecisionTrace.lookupTimeMs },
trace: lastDecisionTrace,
};
}
const result = await cache.lookup(query, embedding);
lastDecisionTrace = {
query,
entities,
structuredKey,
hit: result.found,
tier: result.tier,
method: result.method,
similarity: result.similarity,
reasoning: result.found
? `Cache hit via ${result.method} matching (similarity: ${result.similarity.toFixed(3)}) from ${result.tier} tier.`
: `Cache miss. No entry matched with sufficient similarity.`,
provenance: result.entry?.epistemicProvenance,
lookupTimeMs: result.lookupTimeMs,
};
return { success: true, result, trace: lastDecisionTrace };
}
case 'EXPLAIN': {
if (!lastDecisionTrace) {
return { success: false, error: 'No decision to explain. Perform a LOOKUP first.' };
}
return { success: true, trace: lastDecisionTrace };
}
case 'STORE': {
if (!message.arguments.query || !message.arguments.response) {
return { success: false, error: 'STORE requires query and response arguments' };
}
const id = await cache.store(
message.arguments.query,
message.arguments.embedding || null,
message.arguments.response
);
return { success: true, result: { found: true, similarity: 1, method: 'exact', lookupTimeMs: 0 } };
}
case 'INVALIDATE': {
await cache.clear();
lastDecisionTrace = null;
return { success: true };
}
default:
return { success: false, error: `Unknown message type: ${message.type}` };
}
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Get the last decision trace for explainability.
* Implements Smalltalk-style introspection.
*/
export function explainLastDecision(): CacheDecisionTrace | null {
return lastDecisionTrace;
}
// ============================================================================
// Entity Extraction (Ontology-Driven per Rule 46)
// ============================================================================
@ -783,7 +1300,20 @@ export class SemanticCache {
if (embedding && embedding.length > 0) {
for (const entry of entityCompatibleEntries) {
if (entry.embedding && entry.embedding.length > 0) {
const similarity = cosineSimilarity(embedding, entry.embedding);
const rawSimilarity = cosineSimilarity(embedding, entry.embedding);
// Apply topological distance penalty (Phase 2 - Rule 46 Evolution)
// This prevents "art museum" from matching "natural history museum"
const queryTypeKey = entities.institutionSubtype
? `${entities.institutionType}.${entities.institutionSubtype.toUpperCase().replace(/[^A-Z]/g, '')}`
: (entities.institutionType || undefined);
const cachedEntities = entry.entities || extractEntitiesFast(entry.query);
const cachedTypeKey = cachedEntities.institutionSubtype
? `${cachedEntities.institutionType}.${cachedEntities.institutionSubtype.toUpperCase().replace(/[^A-Z]/g, '')}`
: (cachedEntities.institutionType || undefined);
const similarity = combinedSimilarity(rawSimilarity, queryTypeKey, cachedTypeKey);
if (similarity > bestSimilarity && similarity >= this.config.similarityThreshold) {
bestSimilarity = similarity;
bestMatch = entry;
@ -884,7 +1414,7 @@ export class SemanticCache {
}
/**
* Store a query and response in cache with extracted entities.
* Store a query and response in cache with extracted entities and epistemic provenance.
* Entities are extracted at storage time to enable entity-aware cache matching.
*/
async store(
@ -892,12 +1422,33 @@ export class SemanticCache {
embedding: number[] | null,
response: CachedResponse,
llmProvider: string = 'zai',
language: 'nl' | 'en' = 'nl'
language: 'nl' | 'en' = 'nl',
provenance?: Partial<EpistemicProvenance>
): Promise<string> {
// Extract entities at storage time for future entity-aware matching
const entities = extractEntitiesFast(query);
const structuredKey = generateStructuredCacheKey(entities);
// Build epistemic provenance with defaults
const epistemicProvenance: EpistemicProvenance = {
dataSource: provenance?.dataSource || 'RAG_PIPELINE',
dataTier: provenance?.dataTier || 4,
sourceTimestamp: provenance?.sourceTimestamp || new Date().toISOString(),
derivationChain: provenance?.derivationChain || [`LLM:${llmProvider}`],
revalidationPolicy: provenance?.revalidationPolicy || 'weekly',
confidenceScore: provenance?.confidenceScore,
sourceReferences: provenance?.sourceReferences,
epistemicNotes: provenance?.epistemicNotes,
};
// Determine holon level based on entities
let holonLevel: 'micro' | 'meso' | 'macro' = 'meso';
if (entities.intent === 'info' && entities.location) {
holonLevel = 'micro'; // Specific entity query
} else if (!entities.institutionType && !entities.location) {
holonLevel = 'macro'; // Broad aggregate query
}
const entry: CachedQuery = {
id: generateCacheId(),
query,
@ -910,8 +1461,10 @@ export class SemanticCache {
language,
llmProvider,
source: 'local',
entities, // Store extracted entities for entity-aware matching
structuredKey, // Store structured key for debugging/analytics
entities,
structuredKey,
epistemicProvenance,
holonLevel,
};
// Store locally

View file

@ -13,6 +13,8 @@ import {
generateStructuredCacheKey,
entitiesMatch,
normalizeQuery,
topologicalDistance,
combinedSimilarity,
type ExtractedEntities,
type InstitutionTypeCode,
} from '../src/lib/semantic-cache'
@ -443,3 +445,73 @@ describe('normalizeQuery', () => {
expect(normalizeQuery('musea in amsterdam')).toBe('musea in amsterdam')
})
})
// ============================================================================
// Phase 2: Topological Distance Tests (Rule 46 Evolution)
// ============================================================================
describe('topologicalDistance', () => {
it('should return 0 for identical types', () => {
expect(topologicalDistance('M', 'M')).toBe(0)
expect(topologicalDistance('A', 'A')).toBe(0)
expect(topologicalDistance('M.ART', 'M.ART')).toBe(0)
})
it('should return 0.25 for sibling subtypes (same parent)', () => {
// M.ART and M.HISTORY are both children of M
const dist = topologicalDistance('M.ART', 'M.HISTORY')
expect(dist).toBeCloseTo(0.5, 1) // path: ART -> M -> HISTORY = 2 / 4 = 0.5
})
it('should return higher distance for different base types', () => {
// M and A are siblings under root
const dist = topologicalDistance('M', 'A')
expect(dist).toBeCloseTo(0.5, 1) // path: M -> * -> A = 2 / 4 = 0.5
})
it('should return even higher distance for subtypes of different base types', () => {
// M.ART and A.MUNICIPAL are in different branches
const dist = topologicalDistance('M.ART', 'A.MUNICIPAL')
expect(dist).toBeGreaterThan(0.5)
})
it('should handle unknown types gracefully', () => {
// Unknown types should be treated as direct children of root
const dist = topologicalDistance('UNKNOWN', 'M')
expect(dist).toBeGreaterThanOrEqual(0)
expect(dist).toBeLessThanOrEqual(1)
})
it('should be symmetric', () => {
expect(topologicalDistance('M', 'A')).toBe(topologicalDistance('A', 'M'))
expect(topologicalDistance('M.ART', 'L')).toBe(topologicalDistance('L', 'M.ART'))
})
})
describe('combinedSimilarity', () => {
it('should return pure embedding similarity when no types provided', () => {
const similarity = combinedSimilarity(0.95, undefined, undefined)
expect(similarity).toBe(0.95)
})
it('should weight embedding similarity at 0.7 by default', () => {
// Same type -> topological distance = 0 -> topo similarity = 1
const similarity = combinedSimilarity(0.9, 'M', 'M')
// 0.7 * 0.9 + 0.3 * 1.0 = 0.63 + 0.3 = 0.93
expect(similarity).toBeCloseTo(0.93, 2)
})
it('should penalize different types even with high embedding similarity', () => {
// Different types -> topological distance > 0 -> lower combined similarity
const sameType = combinedSimilarity(0.9, 'M', 'M')
const diffType = combinedSimilarity(0.9, 'M', 'A')
expect(diffType).toBeLessThan(sameType)
})
it('should heavily penalize cross-branch subtype matches', () => {
// M.ART vs A.MUNICIPAL - very different semantically
const crossBranch = combinedSimilarity(0.92, 'M.ART', 'A.MUNICIPAL')
// Even with 0.92 embedding similarity, the topological penalty should be significant
expect(crossBranch).toBeLessThan(0.85)
})
})