@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
210 lines (209 loc) • 8.24 kB
JavaScript
/**
* Readers for a decision model's answer map.
*
* Every reader validates at runtime and returns `undefined` for a missing id
* **or** a type mismatch, so no call site needs a type assertion (CLAUDE.md
* rule 14) and "no opinion" stays distinguishable from "an opinion of 0".
*
* @module utils/decisionAnswers
*/
import { DEFAULT_INFERENCE_KINDS } from "../types/index.js";
/**
* Read a yes/no answer as a probability.
*
* Returns undefined when the id is absent or the answer was a different
* type — a caller can therefore tell "not answered" from "answered 0".
*/
export function readDecisionBoolean(answers, id) {
const answer = answers[id];
return answer?.type === "boolean" ? answer.probability : undefined;
}
/**
* Read a choice answer, including the full ranking.
*
* `ranked` is the distribution sorted highest-first. This is what makes one
* choice question over N options a ranking of all N — the basis for picking
* from a large catalogue in a single request.
*/
export function readDecisionChoice(answers, id) {
const answer = answers[id];
if (answer?.type !== "choice") {
return undefined;
}
const { choice, confidence, probabilities } = answer;
const ranked = Object.entries(probabilities)
.map(([name, probability]) => ({ name, probability }))
.sort((a, b) => b.probability - a.probability);
return { choice, confidence, probabilities, ranked };
}
export function readDecisionScore(answers, id) {
const answer = answers[id];
if (answer?.type !== "score") {
return undefined;
}
const { score, confidence, legend, probabilities } = answer;
return { score, confidence, legend, probabilities };
}
/**
* A yes/no answer carries no confidence of its own, so certainty has to be
* inferred from how far the probability sits from a coin flip. 0.5 → 0, and
* 0 or 1 → 1.
*/
export function decisionBooleanConfidence(probability) {
return Math.abs(probability - 0.5) * 2;
}
/** Whether a descriptor declares support for a given inference type. */
export function servesInferenceKind(descriptor, kind) {
return (descriptor.inferenceKinds ?? DEFAULT_INFERENCE_KINDS).includes(kind);
}
/** Default bars for {@link gateDecisionBoolean}. */
const DEFAULT_BOOLEAN_MIN_PROBABILITY = 0.5;
const DEFAULT_BOOLEAN_MIN_CONFIDENCE = 0.4;
/**
* Read a yes/no answer as an actionable decision, or `undefined` when there
* is not enough signal to act.
*
* Three outcomes, and keeping them distinct is the whole point: `true` (a
* confident yes), `false` (a confident no), and `undefined` (unanswered, the
* wrong type, or too close to a coin flip). Every consumer of the `decide`
* inference type needs exactly this, so the bars live here rather than being
* re-invented — inconsistently — at each call site.
*/
export function gateDecisionBoolean(answers, id, gate) {
const probability = readDecisionBoolean(answers, id);
if (probability === undefined) {
return undefined;
}
const minConfidence = gate?.minConfidence ?? DEFAULT_BOOLEAN_MIN_CONFIDENCE;
if (decisionBooleanConfidence(probability) < minConfidence) {
return undefined;
}
return (probability >= (gate?.minProbability ?? DEFAULT_BOOLEAN_MIN_PROBABILITY));
}
/**
* Build a question id that survives a round trip.
*
* Question ids are the only thing tying an answer back to what it was asked
* about, and a batch mixes heterogeneous questions (one per server, one per
* message, plus gates), so the namespace prefix is what demultiplexes them.
* The index — not the subject's own name — is the key, because ids from the
* wild (server ids, model ids, file paths) are not guaranteed to be distinct
* after any normalisation the wire might apply.
*/
export function decisionKey(namespace, index) {
return `${namespace}__${index}`;
}
/** Namespace for questions a host adds through `decisionHooks.extendQuestions`. */
export const HOST_DECISION_NAMESPACE = "host";
/** Whether a wire id is one the funnel gave a host's question (`host__N`). */
export function isHostDecisionKey(id) {
return id.startsWith(`${HOST_DECISION_NAMESPACE}__`);
}
/**
* A validated copy of a question a host handed back from `extendQuestions`,
* or undefined when it is malformed or cannot be copied. The wire carries
* the copy, never the host's object: a getter, a proxy or a later edit on
* the host's side can then reach neither the request nor the answers keyed
* on it. A value `structuredClone` refuses (a proxy, a function-valued
* field, a throwing getter) is treated as malformed.
*/
export function snapshotDecisionQuestion(value) {
let copy;
try {
copy = structuredClone(value);
}
catch {
return undefined;
}
return isDecisionQuestion(copy) ? copy : undefined;
}
/**
* `result` without the answers to a host's namespaced questions — what a
* consumer is handed when the funnel's host-facing steps failed after the
* wire call had already returned. A null-prototype map, so an own id such
* as `__proto__` survives the copy.
*/
export function stripHostAnswers(result, hostIdByWireId) {
if (hostIdByWireId.size === 0) {
return result;
}
const answers = Object.create(null);
for (const [id, answer] of Object.entries(result.answers)) {
if (!hostIdByWireId.has(id)) {
answers[id] = answer;
}
}
return { ...result, answers };
}
const isRecordValue = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
/**
* Whether `value` is something a decision model accepts as content: a
* string, number or boolean, or an array or plain object of the same.
* `null` and `undefined` are not — a question with no instructions is
* refused by every provider.
*/
function isDecisionInput(value) {
switch (typeof value) {
case "string":
case "boolean":
return true;
case "number":
// NaN and ±Infinity serialise to null and would make the provider
// reject the whole request, not just this question.
return Number.isFinite(value);
case "object":
if (value === null) {
return false;
}
return Array.isArray(value)
? value.every(isDecisionInput)
: Object.values(value).every(isDecisionInput);
default:
return false;
}
}
/**
* Runtime check that a value from outside the type system — a host's
* `extendQuestions` hook — is a well-formed question, so a malformed one
* is dropped on its own rather than failing the whole request that
* NeuroLink's own routing depends on.
*
* A `choice` needs at least two options and a `score` at least two levels;
* fewer carries no information, and the servers refuse it anyway.
*/
export function isDecisionQuestion(value) {
try {
return isWellFormedDecisionQuestion(value);
}
catch {
// Cyclic or otherwise exotic host input: the walk below can overflow the
// stack. That drops this question alone rather than failing the request.
return false;
}
}
function isWellFormedDecisionQuestion(value) {
if (!isRecordValue(value) || !isDecisionInput(value.instructions)) {
return false;
}
switch (value.type) {
case "boolean": {
if (value.criteria === undefined) {
return true;
}
if (!isRecordValue(value.criteria)) {
return false;
}
return Object.values(value.criteria).every((side) => side === null || side === undefined || isDecisionInput(side));
}
case "choice":
return (isRecordValue(value.criteria) &&
Object.keys(value.criteria).length >= 2 &&
Object.values(value.criteria).every((rubric) => rubric === null || isDecisionInput(rubric)));
case "score":
return (Array.isArray(value.criteria) &&
value.criteria.length >= 2 &&
value.criteria.every((level) => level === null || isDecisionInput(level)));
default:
return false;
}
}