/** * Relational resolver for behavioral guidelines. * * Port of boocontext-audit/src/resolver.ts — resolves DEPENDS_ON, * PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES relationships * with an iterative convergence loop. */ // ─── Relationship types (self-contained) ─── export enum RelationshipKind { DEPENDS_ON = 'depends_on', PRIORITIZES = 'prioritizes', ENTAILS = 'entails', TAG_ALL = 'tag_all', TAG_PRIORITIZES = 'tag_prioritizes', } export enum RelationshipEntityKind { GUIDELINE = 'guideline', TAG = 'tag', } export interface RelationshipEntity { id: string; kind: RelationshipEntityKind; } export interface Relationship { id: string; creation_utc: string; source: RelationshipEntity; target: RelationshipEntity; kind: RelationshipKind; group_id?: string; } /** * Minimal relationship store interface. * The resolver only needs listRelationships. Implementations * can back against files, postgres, or in-memory maps. */ export interface RelationshipStore { listRelationships( kind?: RelationshipKind, sourceId?: string, targetId?: string, ): Promise; } // ─── Resolution types ─── export type ResolvedEntityType = 'guideline' | 'journey' | 'tag'; export interface ResolvedEntity { entityType: ResolvedEntityType; entityId: string; } export enum ResolutionKind { NONE = 'none', UNMET_DEPENDENCY = 'unmet_dependency', DEPRIORITIZED = 'deprioritized', ENTAILED = 'entailed', } export interface Resolution { kind: ResolutionKind; description: string; relationshipId?: string; counterparts?: ResolvedEntity[]; } export interface GuidelineStub { id: string; priority: number; tags: string[]; } export interface GuidelineMatchStub { guideline: GuidelineStub; } export interface ResolverResult { matchedIds: Set; resolutions: Map; converged: boolean; iterations: number; } // ─── Constants ─── export const MAX_ITERATIONS = 100; // ─── RelationalResolver ─── export class RelationalResolver { private store: RelationshipStore; constructor(store: RelationshipStore) { this.store = store; } async resolve( matchedIds: Set, allGuidelines: GuidelineStub[], ): Promise { const resolutions = new Map(); const guidelinesById = new Map(allGuidelines.map((g) => [g.id, g])); let currentIds = new Set(matchedIds); const priorityRemoved = new Set(); const entailedIds = new Set(); let converged = false; let iterations = 0; for (iterations = 0; iterations < MAX_ITERATIONS; iterations++) { const candidateIds = new Set( [...currentIds].filter((id) => !priorityRemoved.has(id)), ); const step1Ids = await this.applyDependencies(candidateIds, guidelinesById, resolutions); const step2Ids = await this.applyPrioritization( step1Ids, guidelinesById, resolutions, priorityRemoved, ); const step3Ids = this.applyNumericalPriority( step2Ids, guidelinesById, resolutions, priorityRemoved, entailedIds, ); const step4Ids = await this.applyEntailment( step3Ids, guidelinesById, resolutions, priorityRemoved, entailedIds, ); if (this.setsEqual(step4Ids, currentIds)) { converged = true; break; } currentIds = step4Ids; } for (const id of allGuidelines.map((g) => g.id)) { if (!resolutions.has(id)) { resolutions.set(id, [ { kind: ResolutionKind.NONE, description: 'No relational changes' }, ]); } } return { matchedIds: currentIds, resolutions, converged, iterations: iterations + 1, }; } // ── Private steps ── private async applyDependencies( candidateIds: Set, _guidelinesById: Map, resolutions: Map, ): Promise> { const surviving = new Set(candidateIds); const cache = new Map(); for (const gid of candidateIds) { const rels = await this.getRelationshipsFromCache(cache, gid, RelationshipKind.DEPENDS_ON); for (const rel of rels) { const targetId = rel.target.id; if (!candidateIds.has(targetId)) { surviving.delete(gid); this.addResolution(resolutions, gid, { kind: ResolutionKind.UNMET_DEPENDENCY, description: `Depends on ${targetId} which is not matched`, relationshipId: rel.id, counterparts: [{ entityType: 'guideline' as const, entityId: targetId }], }); break; } } } return surviving; } private async applyPrioritization( candidateIds: Set, guidelinesById: Map, resolutions: Map, priorityRemoved: Set, ): Promise> { const surviving = new Set(candidateIds); const cache = new Map(); for (const gid of candidateIds) { if (priorityRemoved.has(gid)) continue; const allRels = await this.getAllRelationships(cache, gid); const priorityRels = allRels.filter((r) => r.kind === RelationshipKind.PRIORITIZES); for (const rel of priorityRels) { const sourceId = rel.source.id; if (sourceId !== gid) continue; const targetId = rel.target.id; if (candidateIds.has(targetId)) { surviving.delete(targetId); priorityRemoved.add(targetId); this.addResolution(resolutions, targetId, { kind: ResolutionKind.DEPRIORITIZED, description: `Deprioritized by ${gid}`, relationshipId: rel.id, counterparts: [{ entityType: 'guideline' as const, entityId: gid }], }); } } } return surviving; } private applyNumericalPriority( candidateIds: Set, guidelinesById: Map, resolutions: Map, priorityRemoved: Set, entailedIds: Set, ): Set { if (candidateIds.size === 0) return candidateIds; const nonEntailed = [...candidateIds].filter((id) => !entailedIds.has(id)); const entailed = [...candidateIds].filter((id) => entailedIds.has(id)); if (nonEntailed.length === 0) return new Set(entailed); const priorities = nonEntailed.map((id) => guidelinesById.get(id)?.priority ?? 0); const maxPriority = Math.max(...priorities); const surviving = new Set(); for (const id of nonEntailed) { const priority = guidelinesById.get(id)?.priority ?? 0; if (priority >= maxPriority) { surviving.add(id); } else { priorityRemoved.add(id); this.addResolution(resolutions, id, { kind: ResolutionKind.DEPRIORITIZED, description: `Lower priority (${priority} < ${maxPriority})`, }); } } for (const id of entailed) { surviving.add(id); } return surviving; } private async applyEntailment( candidateIds: Set, guidelinesById: Map, resolutions: Map, priorityRemoved: Set, entailedIds: Set, ): Promise> { const result = new Set(candidateIds); const cache = new Map(); for (const gid of candidateIds) { if (priorityRemoved.has(gid)) continue; const allRels = await this.getAllRelationships(cache, gid); const entailRels = allRels.filter((r) => r.kind === RelationshipKind.ENTAILS); for (const rel of entailRels) { const targetId = rel.target.id; if (!guidelinesById.has(targetId)) continue; if (priorityRemoved.has(targetId)) continue; if (entailedIds.has(targetId)) continue; result.add(targetId); entailedIds.add(targetId); this.addResolution(resolutions, targetId, { kind: ResolutionKind.ENTAILED, description: `Entailed by ${gid}`, relationshipId: rel.id, counterparts: [{ entityType: 'guideline' as const, entityId: gid }], }); } } return result; } // ── Cache helpers ── private async getRelationshipsFromCache( cache: Map, gid: string, kind: RelationshipKind, ): Promise { const key = `${kind}:${gid}`; if (!cache.has(key)) { cache.set(key, await this.store.listRelationships(kind, gid)); } return cache.get(key)!; } private async getAllRelationships( cache: Map, gid: string, ): Promise { const result: Relationship[] = []; const kinds = Object.values(RelationshipKind) as RelationshipKind[]; for (const kind of kinds) { const rels = await this.getRelationshipsFromCache(cache, gid, kind); const targetRels = await this.getRelationshipsFromCache(cache, `target:${gid}`, kind); result.push(...rels, ...targetRels); } return result; } private addResolution( resolutions: Map, id: string, resolution: Resolution, ): void { if (!resolutions.has(id)) resolutions.set(id, []); resolutions.get(id)!.push(resolution); } private setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const item of a) if (!b.has(item)) return false; return true; } }