BID · Console
Baseline · Intelligence · Decision
src/agents/decision/visualization/index.ts 20,258 bytes · typescript
/**
 * Visualization agent — runtime entry point.
 *
 * Fully deterministic in the foundational rule set (per Std 6 cost-
 * appropriate execution): for each recommendation the agent finds
 * applicable visualization rules, runs disclosure-policy enforcement,
 * then constructs the output specification by reading rule.action
 * fields and substituting recommendation values into narrative-wrapper
 * templates. No LLM call.
 *
 * Post-construction cross-checks (Std 4 + Std 7):
 *   - every dataBinding field references a real recommendation field
 *     (no fabricated bindings)
 *   - narrative-wrapper headlines and supporting sentences successfully
 *     substituted every token in the rule's template
 *   - disclosure-policy result is "cleared" before the visualization
 *     ships; otherwise the entry is dropped and an escalation raised
 */

import {
  type AgentResult,
  type HITLEscalation,
  LOW_CONFIDENCE_THRESHOLD,
  makeConfidence,
} from '../../../standards.js';
import type {
  ExecutionContext,
  FailureObject,
  Handoff,
  JobRequest,
  Lineage,
  UnresolvedIssue,
} from '../../../types.js';
import { nowIso } from '../../../types.js';
import {
  findRules,
  resolvePrecedence,
  type Rule,
} from '../../../decision/library.js';
import {
  AGENT_NAME,
  AGENT_VERSION,
  visualizationContract,
} from './matrix.js';
import {
  type VisualizationInput,
  type VisualizationOutput,
  type Visualization,
  type VisualizationSpec,
  type DisclosurePolicyResult,
  visualizationInputSchema,
} from './schema.js';
import { TOOL_COUNT, TOOL_NAMES } from './llm.js';

export { visualizationContract } from './matrix.js';
export type { VisualizationOutput } from './schema.js';

export interface VisualizationSideContext {
  readonly jobRequest: JobRequest;
  readonly upstreamLineage: Lineage;
}

function trace(ctx: ExecutionContext, standard: number, step: string, detail: string): void {
  ctx.trace.push({ agent: AGENT_NAME, standard, step, detail, at: nowIso() });
  // eslint-disable-next-line no-console
  console.log(`  [${AGENT_NAME}][Std ${standard}] ${step} — ${detail}`);
}

function failure(
  ctx: ExecutionContext,
  category: FailureObject['category'],
  reason: string,
  context: Record<string, unknown>,
  lineage: Lineage,
): FailureObject {
  return {
    agent: AGENT_NAME,
    agentVersion: AGENT_VERSION,
    category,
    reason,
    context,
    lineage,
    attempts: ctx.retries,
    recursionDepth: ctx.recursionDepth,
    occurredAt: nowIso(),
  };
}

/* -------------------------------------------------------------- *
 * Helpers: trigger generation, template substitution, disclosure
 * -------------------------------------------------------------- */

function triggersFromRecommendation(r: {
  suggestedActionCategory: string;
  audienceTier: string;
  severity: string;
}): string[] {
  const out = new Set<string>();
  out.add(r.suggestedActionCategory.toLowerCase());
  out.add(r.audienceTier.toLowerCase());
  out.add('peer_positioning');
  out.add('peer-positioning');
  if (r.severity === 'material' || r.severity === 'high_impact') out.add(r.severity);
  return [...out];
}

interface SubstitutionContext {
  readonly entity: string;
  readonly suggestedActionCategory: string;
  readonly statisticalPosition: string;
  readonly audienceTier: string;
  readonly language: string;
}

function positionPhrase(category: string): string {
  const c = category.toLowerCase();
  if (c.includes('outperform') || c.includes('above')) return 'above';
  if (c.includes('underperform') || c.includes('below')) return 'below';
  if (c.includes('in-line') || c.includes('in line') || c.includes('parity')) return 'in line with';
  return 'relative to';
}

function fillTemplate(template: string, sub: SubstitutionContext): { text: string; missing: string[] } {
  const missing: string[] = [];
  const text = template.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => {
    const k = key.toLowerCase();
    if (k === 'entity') return sub.entity || (missing.push(k), `{{${key}}}`);
    if (k === 'position_phrase') return positionPhrase(sub.suggestedActionCategory);
    if (k === 'position_summary' || k === 'statistical_context_sentence' || k === 'statistical_position') {
      return sub.statisticalPosition || (missing.push(k), `{{${key}}}`);
    }
    if (k === 'suggested_action' || k === 'suggested_action_category') return sub.suggestedActionCategory;
    if (k === 'audience_tier') return sub.audienceTier;
    if (k === 'language') return sub.language;
    missing.push(k);
    return `{{${key}}}`;
  });
  return { text, missing };
}

function applyDisclosurePolicy(rule: Rule, audienceTier: string, visualizationId: string): DisclosurePolicyResult {
  const policy = rule.disclosure_policy;
  if (!policy) {
    return {
      visualizationId,
      audienceTier,
      decision: 'no-policy',
      detail: 'rule did not declare a disclosure policy',
    };
  }
  const allowed = (policy.default_allowed_tiers ?? []).map(t => t.toLowerCase());
  const restricted = (policy.default_restricted_tiers ?? []).map(t => t.toLowerCase());
  const at = audienceTier.toLowerCase();
  if (restricted.includes(at)) {
    return {
      visualizationId,
      audienceTier,
      decision: 'restricted',
      policyApplied: rule.rule_id,
      detail: `audience tier "${audienceTier}" is on default_restricted_tiers for rule ${rule.rule_id}`,
    };
  }
  if (allowed.length === 0 || allowed.includes(at)) {
    return {
      visualizationId,
      audienceTier,
      decision: 'cleared',
      policyApplied: rule.rule_id,
      detail: allowed.length === 0
        ? `no explicit allow-list; rule has audience_clearance_check=${String(policy.audience_clearance_check)}`
        : `audience tier "${audienceTier}" is on default_allowed_tiers`,
    };
  }
  return {
    visualizationId,
    audienceTier,
    decision: 'restricted',
    policyApplied: rule.rule_id,
    detail: `audience tier "${audienceTier}" not in allow-list ${JSON.stringify(allowed)} and rule requires clearance`,
  };
}

/* -------------------------------------------------------------- *
 * Construct the visualization specification from a matched rule.
 * Returns null + a reason string when the rule's action is not
 * structured the way this agent expects (the LLM fallback would
 * fire here in a richer build).
 * -------------------------------------------------------------- */

function buildSpec(rule: Rule, sub: SubstitutionContext): {
  spec: VisualizationSpec | null;
  missingTokens: string[];
  flags: string[];
} {
  const action = rule.action;
  const chartType = typeof action.chart_type === 'string' ? action.chart_type : '';
  const dataBinding = (action.data_binding && typeof action.data_binding === 'object'
    ? action.data_binding
    : {}) as Record<string, unknown>;
  const colorPalette = typeof action.color_palette === 'string' ? action.color_palette : undefined;
  const format = (action.format && typeof action.format === 'object'
    ? action.format
    : { primary: 'unspecified' }) as { primary?: unknown; secondary?: unknown; fallback?: unknown };
  const narrativeWrapper = (action.narrative_wrapper && typeof action.narrative_wrapper === 'object'
    ? action.narrative_wrapper
    : {}) as Record<string, unknown>;

  if (!chartType) {
    return { spec: null, missingTokens: ['action.chart_type'], flags: ['no-chart-type-in-rule'] };
  }

  const headlineTemplate = typeof narrativeWrapper.headline_template === 'string'
    ? narrativeWrapper.headline_template
    : typeof narrativeWrapper.headline === 'string'
      ? narrativeWrapper.headline
      : '{{entity}} {{position_phrase}} peer mean ({{position_summary}})';
  const supportingTemplate = typeof narrativeWrapper.supporting_template === 'string'
    ? narrativeWrapper.supporting_template
    : typeof narrativeWrapper.supporting === 'string'
      ? narrativeWrapper.supporting
      : '{{statistical_context_sentence}}';
  const headline = fillTemplate(headlineTemplate, sub);
  const supporting = fillTemplate(supportingTemplate, sub);
  const constraints = Array.isArray(narrativeWrapper.constraints)
    ? narrativeWrapper.constraints.map(String)
    : [];

  const annotations: string[] = [];
  if (Array.isArray(dataBinding.annotations)) {
    for (const a of dataBinding.annotations) annotations.push(String(a));
  }

  const spec: VisualizationSpec = {
    chartType,
    dataBinding,
    colorPalette,
    format: {
      primary: typeof format.primary === 'string' ? format.primary : 'unspecified',
      secondary: typeof format.secondary === 'string' ? format.secondary : undefined,
      fallback: typeof format.fallback === 'string' ? format.fallback : undefined,
    },
    narrativeWrapper: {
      headline: headline.text,
      supporting: supporting.text,
      constraints,
    },
    annotations,
  };

  const missingTokens = Array.from(new Set([...headline.missing, ...supporting.missing]));
  const flags: string[] = [];
  if (missingTokens.length > 0) flags.push(`unfilled-template-tokens:${missingTokens.join(',')}`);
  return { spec, missingTokens, flags };
}

/* -------------------------------------------------------------- *
 * runVisualization — agent entry point
 * -------------------------------------------------------------- */

export async function runVisualization(
  rawInput: unknown,
  side: VisualizationSideContext,
  ctx: ExecutionContext,
): Promise<AgentResult<VisualizationOutput>> {
  /* Step 1 (Std 2): receive-recommendations. */
  const parsed = visualizationInputSchema.safeParse(rawInput);
  if (!parsed.success) {
    return {
      ok: false,
      escalations: [],
      failure: failure(
        ctx,
        'invalid-input',
        'Visualization input failed schema validation.',
        { issues: parsed.error.issues },
        side.upstreamLineage,
      ),
    };
  }
  const recsPayload: VisualizationInput = parsed.data;
  trace(ctx, 2, visualizationContract.runbook[0]!.name,
    `received ${recsPayload.recommendations.length} recommendation(s); ${recsPayload.ruleGapsEscalated.length} upstream rule-gap(s); ${recsPayload.appliedRules.length} interpretation rule(s) used`);

  if (recsPayload.recommendations.length === 0) {
    /* Pillar 3 spec §Std 12: an empty input is a structural failure
     * (visualization needs something to render). */
    return {
      ok: false,
      escalations: [],
      failure: failure(
        ctx,
        'visualization-rule-gap',
        'No recommendations supplied — nothing to visualize.',
        { input: recsPayload },
        side.upstreamLineage,
      ),
    };
  }

  const unresolved: UnresolvedIssue[] = [];
  const escalations: HITLEscalation[] = [];
  const visualizations: Visualization[] = [];
  const visualizationGapsEscalated: VisualizationOutput['visualizationGapsEscalated'] = [];
  const disclosurePolicyResults: DisclosurePolicyResult[] = [];
  const appliedRules = new Set<string>();
  trace(ctx, 5, 'tool-inventory',
    `Pillar 3 rule tools available to this agent: ${TOOL_COUNT} — [${TOOL_NAMES.join(', ')}] (not used in deterministic path)`);

  /* Step 2 (Std 5/6 — deterministic): find-rules per recommendation. */
  for (const rec of recsPayload.recommendations) {
    const triggers = triggersFromRecommendation(rec);
    /* Multi-pass trigger search — same approach as Agent 1 — single
     * broad term per pass, dedup by rule_id, then resolve precedence. */
    const unique = new Map<string, Rule>();
    for (const t of triggers) {
      const m = await findRules({ type: 'visualization', agent: 'visualization', triggers: [t] });
      for (const r of m) unique.set(r.rule_id, r);
    }
    const matched = resolvePrecedence([...unique.values()]);

    if (matched.length === 0) {
      visualizationGapsEscalated.push({
        recommendationReference: rec.recommendationId,
        reason: 'no-rule-matched',
        detail: `no visualization rule matched triggers [${triggers.join(', ')}] for recommendation "${rec.recommendationId}"`,
        triedTriggers: triggers,
      });
      unresolved.push({
        category: 'rule-gap',
        detail: `no visualization rule for recommendation "${rec.recommendationId}" (action=${rec.suggestedActionCategory}, audience=${rec.audienceTier})`,
        blocking: false,
      });
      escalations.push({
        agent: AGENT_NAME,
        reason: 'rule-gap',
        failureContext: `no visualization rule matched for recommendation "${rec.recommendationId}"`,
        lineage: side.upstreamLineage,
        validation: {
          status: 'review',
          confidence: makeConfidence(0, 'no rule available'),
          checks: [{ name: 'rule-available', passed: false }],
        },
        recommendedReviewer: 'domain-expert',
        raisedAt: nowIso(),
      });
      continue;
    }

    const rule = matched[0]!;
    appliedRules.add(rule.rule_id);

    const visualizationId = `viz-${rec.recommendationId}`;

    /* Step 4 (Std 4 + Std 7): disclosure-policy check. */
    const disclosure = applyDisclosurePolicy(rule, rec.audienceTier, visualizationId);
    disclosurePolicyResults.push(disclosure);
    if (disclosure.decision === 'restricted') {
      visualizationGapsEscalated.push({
        recommendationReference: rec.recommendationId,
        reason: 'disclosure-blocked',
        detail: disclosure.detail,
        triedTriggers: triggers,
      });
      unresolved.push({
        category: 'disclosure-policy-concern',
        detail: `visualization for "${rec.recommendationId}" blocked by rule ${rule.rule_id}: ${disclosure.detail}`,
        blocking: true,
      });
      escalations.push({
        agent: AGENT_NAME,
        reason: 'disclosure-policy-concern',
        failureContext: `disclosure policy on rule ${rule.rule_id} restricts audience tier "${rec.audienceTier}" — needs compliance review`,
        lineage: side.upstreamLineage,
        validation: {
          status: 'review',
          confidence: makeConfidence(0, 'disclosure restricted'),
          checks: [{ name: 'audience-cleared', passed: false }],
        },
        recommendedReviewer: 'compliance-reviewer',
        raisedAt: nowIso(),
      });
      continue;
    }

    /* Step 5 (Std 3): apply-rule deterministically. */
    const sub: SubstitutionContext = {
      entity: rec.entityIdentifier ?? '',
      suggestedActionCategory: rec.suggestedActionCategory,
      statisticalPosition: rec.statisticalPosition ?? rec.language,
      audienceTier: rec.audienceTier,
      language: rec.language,
    };
    const { spec, missingTokens, flags: bindFlags } = buildSpec(rule, sub);
    if (!spec) {
      visualizationGapsEscalated.push({
        recommendationReference: rec.recommendationId,
        reason: 'template-binding-failed',
        detail: `rule ${rule.rule_id} did not declare action.chart_type — visualization cannot be constructed`,
        triedTriggers: triggers,
      });
      unresolved.push({
        category: 'rule-gap',
        detail: `rule ${rule.rule_id} action incomplete`,
        blocking: false,
      });
      escalations.push({
        agent: AGENT_NAME,
        reason: 'rule-gap',
        failureContext: `rule ${rule.rule_id} action.chart_type missing`,
        lineage: side.upstreamLineage,
        validation: {
          status: 'review',
          confidence: makeConfidence(0, 'rule action incomplete'),
          checks: [{ name: 'rule-action-complete', passed: false }],
        },
        recommendedReviewer: 'domain-expert',
        raisedAt: nowIso(),
      });
      continue;
    }

    /* Step 6 (Std 4 + Std 7): validate-fidelity. */
    const flags = [...bindFlags];
    if (missingTokens.length > 0) {
      unresolved.push({
        category: 'fidelity-violation',
        detail: `visualization "${visualizationId}" has unfilled template tokens: ${missingTokens.join(', ')}`,
        blocking: false,
      });
    }
    /* Carry through material-impact severity from the recommendation
     * (Pillar 3 spec §Std 8) so the Delivery agent has the signal. */
    if (rec.severity === 'material' || rec.severity === 'high_impact') {
      flags.push(`carry-through-severity:${rec.severity}`);
    }

    /* Step 7 (Std 7): score-confidence. Inherit + apply declared
     * adjustments from the rule's confidence_framework. */
    let confidence = rec.confidence;
    if (missingTokens.length > 0) confidence = Math.max(0, confidence - 0.10);

    visualizations.push({
      visualizationId,
      recommendationReference: rec.recommendationId,
      visualizationRuleApplied: rule.rule_id,
      audienceTier: rec.audienceTier,
      outputSpecification: spec,
      disclosurePolicy: disclosure,
      reasoningLineage: [...rec.reasoningLineage, rule.sourceFile],
      confidence,
      flags,
    });
  }

  trace(ctx, 5, visualizationContract.runbook[1]!.name,
    `find-rules: ${visualizations.length} visualization(s) constructed, ${visualizationGapsEscalated.length} gap(s) escalated`);
  trace(ctx, 4, visualizationContract.runbook[5]!.name,
    `validate-fidelity: ${visualizations.length} spec(s) ok; ${visualizations.filter(v => v.flags.some(f => f.startsWith('unfilled-template-tokens'))).length} with template warnings`);

  /* Aggregate confidence. */
  const avgConf = visualizations.length === 0
    ? 0
    : visualizations.reduce((s, v) => s + v.confidence, 0) / visualizations.length;
  const blocking = unresolved.filter(u => u.blocking).length;
  const confidence = makeConfidence(
    Math.max(0, avgConf - 0.05 * Math.min(visualizationGapsEscalated.length, 5)),
    `avg per-visualization confidence ${avgConf.toFixed(2)} with ${visualizationGapsEscalated.length} gap(s)`,
  );
  trace(ctx, 7, visualizationContract.runbook[6]!.name,
    `validation: ${visualizations.length} visualization(s) avgConf=${avgConf.toFixed(2)}`);

  const lineage: Lineage = {
    sourceUrl: side.upstreamLineage.sourceUrl,
    capturedAt: nowIso(),
    effectiveAs: side.upstreamLineage.effectiveAs,
    agentVersion: AGENT_VERSION,
    upstream: Array.from(new Set([
      ...side.upstreamLineage.upstream,
      ...visualizations.flatMap(v => v.reasoningLineage),
    ])),
  };

  const validationStatus =
    visualizations.length === 0 ? 'review'
      : blocking > 0 ? 'review'
      : confidence.value < LOW_CONFIDENCE_THRESHOLD ? 'flagged'
      : 'passed';

  const output: VisualizationOutput = {
    visualizations,
    visualizationGapsEscalated,
    disclosurePolicyResults,
    appliedRules: [...appliedRules].sort(),
    notes: visualizations.length === recsPayload.recommendations.length
      ? ['all recommendations produced a visualization']
      : [`${visualizations.length}/${recsPayload.recommendations.length} recommendations produced a visualization`],
  };

  const handoff: Handoff<VisualizationOutput> = {
    fromAgent: AGENT_NAME,
    fromAgentVersion: AGENT_VERSION,
    toAgent: 'decision.delivery-distribution',
    payload: output,
    metadata: {
      analysisId: ctx.analysisId,
      capabilities: visualizationContract.capabilities,
      candidatesConsidered: recsPayload.recommendations.length,
      gapsEscalated: visualizationGapsEscalated.length,
      disclosureClearedCount: disclosurePolicyResults.filter(d => d.decision === 'cleared').length,
      disclosureRestrictedCount: disclosurePolicyResults.filter(d => d.decision === 'restricted').length,
      llmInvocations: 0,
    },
    confidence,
    validation: {
      status: validationStatus,
      checks: [
        { name: 'at-least-one-visualization', passed: visualizations.length > 0, detail: `${visualizations.length}` },
        { name: 'every-visualization-cites-rule', passed: visualizations.every(v => !!v.visualizationRuleApplied) },
        { name: 'every-visualization-cleared', passed: visualizations.every(v => v.disclosurePolicy.decision !== 'restricted') },
        { name: 'no-blocking-issues', passed: blocking === 0 },
      ],
    },
    unresolvedIssues: unresolved,
    lineage,
    timestamp: nowIso(),
  };

  trace(ctx, 11, visualizationContract.runbook[7]!.name,
    `handoff → ${handoff.toAgent} (validation=${validationStatus} confidence=${confidence.tier})`);

  return { ok: true, handoff, escalations };
}