Model resolution (from oh-my-openagent/model-core): 6-step priority resolution pipeline (UI select -> user config -> category default -> user fallback -> policy chain -> system default), provider fallback chains, fuzzy model matching, error classification, provider-specific model ID transforms. 14 files, zero runtime deps. Multi-batch matcher (from boocontext-audit): 6 batch types (Observational, Actionable, PreviouslyApplied, Disambiguation, ResponseAnalysis, LowCriticality) for behavioral guideline evaluation. RelationalResolver with iterative convergence (DEPENDS_ON, PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES). SchematicGenerator abstract class with retry and execution plans. 4 files.
356 lines
9.5 KiB
TypeScript
356 lines
9.5 KiB
TypeScript
/**
|
|
* 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<Relationship[]>;
|
|
}
|
|
|
|
// ─── 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<string>;
|
|
resolutions: Map<string, Resolution[]>;
|
|
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<string>,
|
|
allGuidelines: GuidelineStub[],
|
|
): Promise<ResolverResult> {
|
|
const resolutions = new Map<string, Resolution[]>();
|
|
const guidelinesById = new Map(allGuidelines.map((g) => [g.id, g]));
|
|
let currentIds = new Set(matchedIds);
|
|
const priorityRemoved = new Set<string>();
|
|
const entailedIds = new Set<string>();
|
|
|
|
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<string>,
|
|
_guidelinesById: Map<string, GuidelineStub>,
|
|
resolutions: Map<string, Resolution[]>,
|
|
): Promise<Set<string>> {
|
|
const surviving = new Set(candidateIds);
|
|
const cache = new Map<string, Relationship[]>();
|
|
|
|
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<string>,
|
|
guidelinesById: Map<string, GuidelineStub>,
|
|
resolutions: Map<string, Resolution[]>,
|
|
priorityRemoved: Set<string>,
|
|
): Promise<Set<string>> {
|
|
const surviving = new Set(candidateIds);
|
|
const cache = new Map<string, Relationship[]>();
|
|
|
|
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<string>,
|
|
guidelinesById: Map<string, GuidelineStub>,
|
|
resolutions: Map<string, Resolution[]>,
|
|
priorityRemoved: Set<string>,
|
|
entailedIds: Set<string>,
|
|
): Set<string> {
|
|
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<string>();
|
|
|
|
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<string>,
|
|
guidelinesById: Map<string, GuidelineStub>,
|
|
resolutions: Map<string, Resolution[]>,
|
|
priorityRemoved: Set<string>,
|
|
entailedIds: Set<string>,
|
|
): Promise<Set<string>> {
|
|
const result = new Set(candidateIds);
|
|
const cache = new Map<string, Relationship[]>();
|
|
|
|
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<string, Relationship[]>,
|
|
gid: string,
|
|
kind: RelationshipKind,
|
|
): Promise<Relationship[]> {
|
|
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<string, Relationship[]>,
|
|
gid: string,
|
|
): Promise<Relationship[]> {
|
|
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<string, Resolution[]>,
|
|
id: string,
|
|
resolution: Resolution,
|
|
): void {
|
|
if (!resolutions.has(id)) resolutions.set(id, []);
|
|
resolutions.get(id)!.push(resolution);
|
|
}
|
|
|
|
private setsEqual(a: Set<string>, b: Set<string>): boolean {
|
|
if (a.size !== b.size) return false;
|
|
for (const item of a) if (!b.has(item)) return false;
|
|
return true;
|
|
}
|
|
}
|