agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
254 lines • 10.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.assertUniqueIds = exports.assertValidWeights = exports.embedAll = exports.scoreInfluence = exports.compositeScore = exports.adaptWeights = exports.structuralProximity = exports.persistence = exports.averageRelevancy = exports.finalAnswerSimilarity = void 0;
/**
* FDL influence signals — the four-signal composite from the Visible
* Reasoning paper (Eq. 1–6), extracted verbatim as RFC-003 block D6.
*
* Pattern: pure scorer functions + one async orchestrator. Vector-level
* functions are deterministic and embedder-free; only
* `scoreInfluence` touches the injected `Embedder`.
* Role: `src/influence-core/` leaf. Consumers: the FDL paper
* pipeline (stage 5, computeInfluenceScores), RFC-003 D7's
* LLM-edge weigher, and — one level up — RFC-002's margin
* scoring shares the same geometry via `margin.ts`.
*
* ## Honest claim per signal (RFC-002 §2 discipline)
*
* Every signal is embedding GEOMETRY — a deterministic proxy, not a
* window into the model:
*
* - FA "the tool's output is semantically close to the final
* answer" — NOT "the answer was derived from it".
* - AVG "the tool's output stayed semantically close to the
* reasoning steps" — NOT "the model kept consulting it".
* - PERSIST "many reasoning steps are similar to it above T" — breadth
* of apparent reference, NOT counted citations.
* - DEPTH pure structure (1/(1+ancestors)) — directness of position
* in the trace, knows nothing about content at all.
*
* The composite S(d) means "high semantic alignment with the answer",
* never "this source contributed X% of the answer" (paper §5.2: scores
* are per-item, not additive, not causal attribution). Same inputs →
* same scores, unlike LLM-as-judge.
*/
const cosine_js_1 = require("../memory/embedding/cosine.js");
const types_js_1 = require("./types.js");
/**
* FA — Final Answer Similarity (paper Eq. 1).
*
* `FA(d) = sim(e_d, e_f)` — cosine between the evidence embedding and
* the final-answer embedding. The strongest prior: verbatim or
* paraphrased reuse of a tool result scores high. Proxy: semantic
* overlap, not provenance.
*/
function finalAnswerSimilarity(evidenceVec, finalAnswerVec) {
return (0, cosine_js_1.cosineSimilarity)(evidenceVec, finalAnswerVec);
}
exports.finalAnswerSimilarity = finalAnswerSimilarity;
/**
* AVG — Average Relevancy (paper Eq. 2).
*
* Mean cosine between the evidence and each LLM reasoning ancestor;
* 0 when there are no ancestors (structurally zero — see
* `adaptWeights`). Proxy: consistent semantic closeness across the
* chain, not actual consultation.
*/
function averageRelevancy(evidenceVec, ancestorVecs) {
const n = ancestorVecs.length;
if (n === 0)
return 0;
let sum = 0;
for (const ancestorVec of ancestorVecs) {
sum += (0, cosine_js_1.cosineSimilarity)(evidenceVec, ancestorVec);
}
return sum / n;
}
exports.averageRelevancy = averageRelevancy;
/**
* PERSIST — Persistence (paper Eq. 3).
*
* Fraction of ancestors whose similarity to the evidence EXCEEDS the
* threshold T (strict `>`, default 0.30); 0 when there are no
* ancestors. Unlike AVG it measures BREADTH: referenced in 4 of 5
* steps (0.8) beats referenced intensely in 1. Proxy: similarity
* above a tunable bar, not counted citations.
*/
function persistence(evidenceVec, ancestorVecs, threshold = types_js_1.DEFAULT_PERSISTENCE_THRESHOLD) {
const n = ancestorVecs.length;
if (n === 0)
return 0;
let above = 0;
for (const ancestorVec of ancestorVecs) {
if ((0, cosine_js_1.cosineSimilarity)(evidenceVec, ancestorVec) > threshold)
above += 1;
}
return above / n;
}
exports.persistence = persistence;
/**
* DEPTH — Structural Proximity (paper Eq. 4).
*
* `DEPTH(d) = 1 / (1 + n)` where n counts LLM reasoning ancestors
* ONLY (not pipeline plumbing — callers decide what counts as an
* ancestor when building `EvidenceInput.ancestorTexts`). Direct
* evidence with no intermediaries gets exactly 1.0. The only
* content-blind signal: pure trace structure.
*/
function structuralProximity(ancestorCount) {
if (!Number.isInteger(ancestorCount) || ancestorCount < 0) {
throw new Error(`structuralProximity: ancestorCount must be a non-negative integer (got ${ancestorCount})`);
}
return 1 / (1 + ancestorCount);
}
exports.structuralProximity = structuralProximity;
/**
* Adaptive weight redistribution (paper Eq. 6, §5.3).
*
* When an item has NO LLM ancestors, AVG and PERSIST are structurally
* zero — not because the evidence was uninfluential, but because there
* is nothing to measure against. Without adaptation its score is
* capped at α+δ (≈0.50 under defaults). Eq. 6 moves the β+γ mass onto
* FA and DEPTH preserving their ratio:
*
* α′ = α + (β+γ)·α/(α+δ), δ′ = δ + (β+γ)·δ/(α+δ), β′ = γ′ = 0
*
* Defaults → α′=0.80, δ′=0.20 (the 4:1 FA:DEPTH ratio kept).
* Per-evidence-item: in a multi-tool pipeline some items adapt while
* others keep standard weights; `adapted` says which (surface it — the
* paper's UI marks adapted items).
*
* Degenerate guard: if α+δ = 0 there is no defined ratio to preserve —
* weights return unchanged with `adapted: false`, and the composite is
* honestly 0 for a no-ancestor item.
*/
function adaptWeights(weights, ancestorCount) {
if (ancestorCount > 0)
return { weights, adapted: false };
const base = weights.fa + weights.depth;
if (base === 0)
return { weights, adapted: false };
const mass = weights.avg + weights.persist;
return {
weights: {
fa: weights.fa + (mass * weights.fa) / base,
avg: 0,
persist: 0,
depth: weights.depth + (mass * weights.depth) / base,
},
adapted: mass > 0,
};
}
exports.adaptWeights = adaptWeights;
/**
* Composite score S(d) (paper Eq. 5).
*
* `S = α·FA + β·AVG + γ·PERSIST + δ·DEPTH` under the given weights —
* pass the EFFECTIVE weights from `adaptWeights` for no-ancestor
* items. With weights summing to 1, S ∈ [−(α+β), 1] (FA/AVG are
* cosines and may go negative; PERSIST/DEPTH are non-negative).
*/
function compositeScore(signals, weights) {
return (weights.fa * signals.fa +
weights.avg * signals.avg +
weights.persist * signals.persist +
weights.depth * signals.depth);
}
exports.compositeScore = compositeScore;
/**
* Score every evidence item on the four FDL signals and rank by
* composite, descending (paper pipeline stages 4–6 in one call:
* embed → score → rank). Ties keep input order (stable sort).
*
* Deterministic for a deterministic embedder: same inputs → same
* scores. All texts are embedded in ONE deduplicated batch — with an
* `EmbeddingCache` injected, repeat calls embed nothing.
*
* Honest claim: ranked semantic-alignment proxies. NOT causal
* attribution — see module docs.
*/
async function scoreInfluence(args) {
const weights = args.weights ?? types_js_1.DEFAULT_INFLUENCE_WEIGHTS;
assertValidWeights(weights);
const threshold = args.persistenceThreshold ?? types_js_1.DEFAULT_PERSISTENCE_THRESHOLD;
assertUniqueIds(args.evidence);
// ONE deduplicated embedding pass over every distinct text.
const texts = new Set([args.finalAnswerText]);
for (const item of args.evidence) {
texts.add(item.text);
for (const ancestor of item.ancestorTexts)
texts.add(ancestor);
}
const vectorByText = await embedAll(args.embedder, [...texts], args.signal);
const finalVec = vectorByText.get(args.finalAnswerText);
const scored = args.evidence.map((item) => {
const evidenceVec = vectorByText.get(item.text);
const ancestorVecs = item.ancestorTexts.map((t) => vectorByText.get(t));
const signals = {
fa: finalAnswerSimilarity(evidenceVec, finalVec),
avg: averageRelevancy(evidenceVec, ancestorVecs),
persist: persistence(evidenceVec, ancestorVecs, threshold),
depth: structuralProximity(ancestorVecs.length),
};
const effective = adaptWeights(weights, ancestorVecs.length);
return {
id: item.id,
signals,
weights: effective.weights,
adapted: effective.adapted,
score: compositeScore(signals, effective.weights),
};
});
// Stable sort — equal scores keep evidence input order.
return scored.sort((a, b) => b.score - a.score);
}
exports.scoreInfluence = scoreInfluence;
/** Embed distinct texts via batch API when available, else sequentially. */
async function embedAll(embedder, texts, signal) {
const vectors = embedder.embedBatch
? await embedder.embedBatch({ texts, ...(signal ? { signal } : {}) })
: await sequentialEmbed(embedder, texts, signal);
const byText = new Map();
for (let i = 0; i < texts.length; i++)
byText.set(texts[i], vectors[i]);
return byText;
}
exports.embedAll = embedAll;
async function sequentialEmbed(embedder, texts, signal) {
const out = [];
for (const text of texts) {
out.push(await embedder.embed({ text, ...(signal ? { signal } : {}) }));
}
return out;
}
/**
* Validate composite weights: every weight finite & non-negative, and not all
* zero. Shared by `scoreInfluence` and `scoreContrastiveInfluence` — `fnName`
* attributes the error to the actual caller.
*/
function assertValidWeights(weights, fnName = 'scoreInfluence') {
for (const [name, value] of Object.entries(weights)) {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${fnName}: weight '${name}' must be a finite non-negative number (got ${value})`);
}
}
if (weights.fa + weights.avg + weights.persist + weights.depth === 0) {
throw new Error(`${fnName}: all weights are zero — the composite would always be 0`);
}
}
exports.assertValidWeights = assertValidWeights;
/**
* Validate that every evidence id is unique. Shared by `scoreInfluence` and
* `scoreLexicalInfluence` — `fnName` attributes the error to the actual caller.
*/
function assertUniqueIds(evidence, fnName = 'scoreInfluence') {
const seen = new Set();
for (const item of evidence) {
if (seen.has(item.id)) {
throw new Error(`${fnName}: duplicate evidence id '${item.id}' — ids must be unique`);
}
seen.add(item.id);
}
}
exports.assertUniqueIds = assertUniqueIds;
//# sourceMappingURL=signals.js.map