Files
worldmonitor/todos/089-complete-p2-keyactorroles-guardrail-iife-extract-to-named-function.md
Elie Habib 2fddee6b05 feat(simulation): add keyActorRoles to fix actor overlap bonus vocabulary mismatch (#2582)
* feat(simulation): add keyActorRoles field to fix actor overlap bonus vocabulary mismatch

The +0.04 actor overlap bonus never reliably fired in production because
stateSummary.actors uses role-category strings ('Commodity traders',
'Policy officials') while simulation keyActors uses named geo-political
entities ('Iran', 'Houthi'). 53 production runs audited showed the bonus
fired once out of 53.

Fix: add keyActorRoles?: string[] to SimulationTopPath. The Round 2 prompt
now includes a CANDIDATE ACTOR ROLES section with theater-local role vocab
seeded from candidatePacket.stateSummary.actors. The LLM copies matching
roles into keyActorRoles. applySimulationMerge scores overlap against
keyActorRoles when actorSource=stateSummary, preserving the existing
keyActors entity-overlap path for the affectedAssets fallback.

- buildSimulationPackageFromDeepSnapshot: add actorRoles[] to each theater
  from candidate.stateSummary.actors (theater-scoped, no cross-theater noise)
- buildSimulationRound2SystemPrompt: inject CANDIDATE ACTOR ROLES section
  with exact-copy instruction and keyActorRoles in JSON template
- tryParseSimulationRoundPayload: extract keyActorRoles from round 2 output
- mergedPaths.map(): filter keyActorRoles against theater.actorRoles guardrail
- computeSimulationAdjustment: dual-path overlap — roleOverlapCount for
  stateSummary, keyActorsOverlapCount for affectedAssets (backwards compat)
- summarizeImpactPathScore: project roleOverlapCount + keyActorsOverlapCount
  into path-scorecards.json simDetail

New fields: roleOverlapCount, keyActorsOverlapCount in SimulationAdjustmentDetail
and ScorecardSimDetail. actorOverlapCount preserved as backwards-compat alias.

Tests: 308 pass (was 301 before). New tests T-P1/T-P2/T-P3 (prompt/parser),
T-RO1/T-RO2/T-RO3 (role overlap logic), T-PKG1 (pkg builder actorRoles),
plus fixture updates for T2/T-F/T-G/T-J/T-K/T-N2/T-SC-4.

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.49.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(simulation): address CE review findings from PR #2582

- Add SimulationPackageTheater interface to seed-forecasts.types.d.ts
  (actorRoles was untyped under @ts-check)
- Add keyActorRoles to uiTheaters Redis projection in writeSimulationOutcome
  (field was stripped from Redis snapshot; only visible in R2 artifact)
- Extract keyActorRoles IIFE to named sanitizeKeyActorRoles() function;
  hoist allowedRoles Set computation out of per-path loop
- Harden bonusOverlap ternary: explicit branch for actorSource='none'
  prevents silent fallthrough if new actorSource values are added
- Eliminate roleOverlap intermediate array in computeSimulationAdjustment
- Add U+2028/U+2029 Unicode line-separator stripping to sanitizeForPrompt
- Apply sanitizeForPrompt at tryParseSimulationRoundPayload parse boundary;
  add JSDoc to newly-exported function

All 308 tests pass, typecheck + typecheck:api clean.

* fix(sim): restore const sanitized in sanitizeKeyActorRoles after early-return guard

Prior edit added `if (!allowedRoles.length) return []` but accidentally removed
the `const sanitized = ...` line, leaving the filter on line below referencing an
undefined variable. Restores the full function body:

  if (!allowedRoles.length) return [];
  const sanitized = (Array.isArray(rawRoles) ? rawRoles : [])
    .map((s) => sanitizeForPrompt(String(s)).slice(0, 80));
  const allowedNorm = new Set(allowedRoles.map(normalizeActorName));
  return sanitized.filter((s) => allowedNorm.has(normalizeActorName(s))).slice(0, 8);

308/308 tests pass.

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
2026-04-01 08:53:13 +04:00

2.9 KiB
Raw Permalink Blame History

status, priority, issue_id, tags, dependencies
status priority issue_id tags dependencies
pending p2 089
code-review
quality
simulation
simplicity

Extract keyActorRoles guardrail IIFE to named sanitizeKeyActorRoles function

Problem Statement

The keyActorRoles guardrail in mergedPaths.map() is implemented as an 8-line IIFE. The logic is non-trivial (sanitize → check allowlist via normalizeActorName → filter), runs once per path per theater, and is currently untestable in isolation. IIFEs of this complexity violate the project's "extractable at natural boundaries" principle. Additionally, allowed.map(normalizeActorName) is recomputed fresh for every path, even though theater.actorRoles is constant across all paths in a theater.

Findings

  • Flagged by kieran-typescript-reviewer (HIGH) and code-simplicity-reviewer independently
  • Performance: allowedNorm Set rebuilt per path (up to 5 paths × 12 roles = 60 redundant normalizeActorName calls per theater)
  • The IIFE has no dedicated test — the guardrail filter behavior is only exercised implicitly through T-K (which covers the stateSummary path)
  • The sanitize-before-filter ordering could cause a latent mismatch: sanitizeForPrompt(s).slice(0,80) is applied before normalizeActorName(s) comparison (normalizeActorName normalizes the truncated string, which is correct — but the intent is clearer with filter-first)

Proposed Solution

Extract to a named function near the other sim helpers:

/**
 * @param {string[] | undefined} rawRoles - LLM-returned role strings from keyActorRoles
 * @param {string[]} allowedRoles - theater.actorRoles allowlist (empty = no semantic filter)
 * @returns {string[]}
 */
function sanitizeKeyActorRoles(rawRoles, allowedRoles) {
  const sanitized = (Array.isArray(rawRoles) ? rawRoles : [])
    .map((s) => sanitizeForPrompt(String(s)).slice(0, 80));
  if (!Array.isArray(allowedRoles) || allowedRoles.length === 0) return sanitized.slice(0, 8);
  const allowedNorm = new Set(allowedRoles.map(normalizeActorName));
  return sanitized.filter((s) => allowedNorm.has(normalizeActorName(s))).slice(0, 8);
}

Then in mergedPaths.map():

keyActorRoles: sanitizeKeyActorRoles(p.keyActorRoles, theater.actorRoles),

And hoist (optional but clean):

const allowedRoles = Array.isArray(theater.actorRoles) ? theater.actorRoles : [];
// pass allowedRoles to sanitizeKeyActorRoles for all paths in this theater

Technical Details

  • Files: scripts/seed-forecasts.mjs
  • Effort: Small | Risk: Low

Acceptance Criteria

  • sanitizeKeyActorRoles is a named function (not IIFE)
  • allowedNorm Set not recomputed per path when allowed is constant per theater
  • npm run test:data passes
  • New unit test for sanitizeKeyActorRoles (allowlist match, allowlist miss, empty allowed)

Work Log

  • 2026-03-31: Identified by kieran-typescript-reviewer and code-simplicity-reviewer during PR #2582 review