Skip to main content

09 — AI, Policies, and Activities

This doc covers the current SECS AI subsystem built around policies, selectors, needs, rule decisions, and activity candidates:

  • policy — a first-class declaration of compile-time AI decision logic. A policy ties an actor scope to a behavior domain (Survival, Economy, Combat, Travel, Forage…) and carries need, selector, and rule Decision sub-declarations.
  • need — a declarative goal carried by a policy. Names a channel, a target value, an urgency curve, a threshold, and a weight. Read by the utility-AI selector when scoring candidates.
  • selector — a candidate-source declaration that builds ActivityCandidate lists for call_best to score and pick from.
  • rule Decision — a method-bodied dispatch that returns one of seven RuleDecision cases (continue, complete, fail, wait, call(...), call_best(...), cancel_child).
  • PolicyDispatcherV2 (src/SECS.Runtime/Policies/PolicyDispatcherV2.cs) — the engine runtime that walks each policy's rule rows in ascending Order per actor and dispatches. The dispatcher processes all seven decision kinds (Continue, Complete, Fail, Call, Wait, CallBest, CancelChild); only an enum value outside the committed list throws (InvalidOperationException). CallBest scoring is opt-in wiring: the dispatcher is constructed with a NeedCurveRegistry and a SelectorResolver (both or neither), and a CallBest reached without that wiring fails loud. The utility-AI scorer (selectors, needs, response curves, deterministic candidate arbitration) ships under src/SECS.Runtime/Policies/Scoring/.

Executable behavior is activity + policy. Valenar docs should use activity as the canonical behavior / planning noun, with more specific terms such as Mission or Assignment where they clarify the player-facing surface. Read 04-behavior.md § Policies for the lowering contract; this doc covers the AI scoring math and the philosophy of needs-vs-effects.

The design is motivated by three concrete pressure tests:

  1. The AI must score crafted/recipe-based items correctly without per-template AI weights — there are too many channels and too many runtime modifier combinations for hand-authored scoring formulas to scale.
  2. The player must be able to configure AI behavior in-game (toggle rules, tune thresholds, add custom rules) without recompiling the game.
  3. The target AI design must persist behavior across save/load, including mid-execution policy state, with a migration story when definitions change between versions. v1 had partial store-level primitives only. v2 implements entities, relations, modifier bindings, saved scopes, PRNG, activity runs, and pending choices via SecsSavePayload (docs/runtime/rn-save-load-determinism.md). Per-actor mutable policy slot contents (rule / need / selector / known-spell lists) persist through SecsRuntimeSaveSection.PolicySlots (PolicySlotSaveRow), and policy-child linkage (PolicyDispatcherV2's active-children map) persists through SecsRuntimeSaveSection.PolicyActiveChildren (PolicyActiveChildSaveRow).

Prerequisites

  • 00-overview.md — the SECS skeleton and doc map.
  • 01-world-shape.mdscope, contract, top-level channel declarations.
  • 02-templates.md — template declarations, fields, channels.
  • 03-channels-and-modifiers.md — channel resolution pipeline, modifier stacks.
  • 04-behavior.mdsystem, event, on_action, activity, and the ## Policies section that defines the lowering contract.
  • 08-collections-and-propagation.mdScopedList<T> / ScopedDictionary<TKey, T>, used for the player-authored extension surfaces below.

Rules

rule Decision Name() { ... } — method-bodied dispatch

What: A method that the host evaluates each time the policy runs. It inspects the actor's current state and returns a RuleDecision. Multiple rules per policy; walked in declaration order (or by explicit Order slot when the lowering keeps a numeric ordering).

SECS:

rule Decision EmergencyRest()
{
if (actor.resolve(HP_Current) * 4 < actor.resolve(HP_Max))
return call_best(RestSelector);
return continue;
}

rule Decision EatWhenHungry()
{
if (actor.resolve(Hunger) > 50)
return call_best(FoodSelector);
return continue;
}

rule Decision DoneWhenHealthy()
{
if (actor.resolve(HP_Current) >= actor.resolve(HP_Max))
return complete;
return continue;
}

Compiles to:

// Registered via SecsRuntimeBuilder.WithRuleHandler(ruleId, handler).
// Handler signature: RuleDecision Handler(in RuleEvaluationContext context).
private static RuleDecision EvaluateEmergencyRest(in RuleEvaluationContext context)
{
var hp = context.ChannelResolver.Resolve(context.Actor, new ChannelId(H.HP_Current));
var hpMax = context.ChannelResolver.Resolve(context.Actor, new ChannelId(H.HP_Max));
if (hpMax > 0 && hp * 4 < hpMax)
// call_best lowers to RuleDecisionKind.CallBest via the
// RuleDecision.CallBest(selectorId, args) factory. The dispatcher
// resolves the selector's candidates, scores them against the
// policy's needs, and starts the argmax winner through the same
// path as a literal call.
return RuleDecision.CallBest(new SelectorId(H.Selector_RestSelector));
return RuleDecision.Continue;
}

ChannelResolverV2.Resolve(EntityHandle, ChannelId) returns double; there is no ResolveIntFast in v2. The dispatcher walks each registered rule handler through RuleHandlerRegistry.Invoke(ruleId, in context) in ascending rule Order; Complete, Fail, and Wait terminate the walk, while Call, CallBest, and CancelChild take effect and advance to the next rule. The per-rule EvaluateRule(RuleId, …) switch is the v1 dispatch shape.

Why this shape: The user pressure was that the previous rule X when Y call Z; micro-syntax was hard to read because it didn't share shape with established query / method declarations. Rules-as-method-bodies match exactly: function name, parameter list, C# body. Imperative inside the body, declarative as a registered rule. The keyword rule Decision (vs reusing method) is justified because rules have a specific runtime contract — the executor walks them in order, reads the returned RuleDecision, and stops on first dispatch.

RuleDecision — the discriminated return type

In v2 RuleDecision is a single value-typed readonly record struct with a RuleDecisionKind discriminator and payload fields, not an abstract record with sealed sub-records (the v1 shape). RuleDecisionKind (src/SECS.Runtime/Policies/RuleDecisionKind.cs) is Continue=0, Complete=1, Fail=2, Call=3, Wait=4, CallBest=5, CancelChild=6:

public readonly record struct RuleDecision(
RuleDecisionKind Kind,
ActivityId ActivityId,
EntityHandle Target,
SelectorId SelectorId,
ActivityArgsBlob Args)
{
public static readonly RuleDecision Continue = new(RuleDecisionKind.Continue, ...);
public static readonly RuleDecision Complete = new(RuleDecisionKind.Complete, ...);
public static readonly RuleDecision Fail = new(RuleDecisionKind.Fail, ...);
public static readonly RuleDecision Wait = new(RuleDecisionKind.Wait, ...);
public static readonly RuleDecision CancelChild = new(RuleDecisionKind.CancelChild, ...);
public static RuleDecision Call(ActivityId activityId, EntityHandle target, ActivityArgsBlob args = default);
public static RuleDecision CallBest(SelectorId selectorId = default, ActivityArgsBlob args = default);
}
CaseMeaningv2 status
ContinueRule had nothing to say; move to the next rule.Built.
CompleteMark the policy run successful; no further rules this tick.Built.
FailMark the policy run failed; no further rules this tick.Built.
WaitPark this tick; keep any active child intact.Built — the walk stops and returns PolicyDispatchResult.Wait; the next tick re-evaluates fresh.
Call(activity, target, args)Dispatch a specific activity directly.Built.
CallBest(selector)Dispatch the highest-scoring candidate from the named selector (SelectorId.None scores the union of the policy's selectors).Built — requires the dispatcher's opt-in scorer wiring (NeedCurveRegistry + SelectorResolver); a CallBest without that wiring fails loud.
CancelChildCancel the actor's current child run before re-evaluating.Built — cancels the tracked child through the executor (firing OnCancel), then advances.

The no-payload cases are static readonly singleton values (RuleDecision.Continue, .Complete, .Fail, .Wait, .CancelChild) so trivial branches ("nothing to say this tick") do not allocate. Call carries its activity / target / args payload through the struct's fields; CallBest carries a selector id plus args, and the scorer chooses the activity.

Needs

need Name { channel = ...; target = ...; curve = ...; threshold = N; weight = N; }

What: A declarative goal carried by a policy. Names a channel the actor cares about, a target value, an urgency curve, a threshold (where urgency = 1.0), and a weight (the contribution to the final score). The need is read by the utility-AI scorer (NeedScorer, src/SECS.Runtime/Policies/Scoring/NeedScorer.cs) when evaluating call_best: each need is scored as one consideration — the current and target channel values are read for the actor, the current value is normalized into the bookended [Threshold, target] range, and the normalized input runs through the need's registered response curve. The v2 NeedRow carries only declaration metadata; the curve binds at runtime per NeedId through NeedCurveRegistry. The shipped IAUS aggregation is multiplicative with a compensation factor and does not yet apply Weight.

SECS:

policy CharacterSurvival
{
actor Character;
domain Survival;

need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 50;
weight = 100;
}

need KeepFed
{
channel = Stamina_Current;
target = Stamina_Max;
curve = inverse_quad;
threshold = 30;
weight = 60;
}
}

Compiles to:

// Needs.Build() returns NeedRow[] assembled into WorldSchema by
// SecsModule.BuildSchema(). NeedRow is a flat positional record struct;
// the curve is NOT a row field — it maps from NeedId through a runtime
// registry (mirroring the formula / event-option resolver pattern).
public static NeedRow[] Build() => new[]
{
new NeedRow(
Id: new NeedId(H.NeedCharacterSurvivalStayAlive),
OwnerPolicyId: new PolicyId(H.PolicyCharacterSurvival),
ChannelId: new ChannelId(H.HP_Current),
TargetChannelId:new ChannelId(H.HP_Max),
Threshold: 50,
Weight: 100),
new NeedRow(
Id: new NeedId(H.NeedCharacterSurvivalKeepFed),
OwnerPolicyId: new PolicyId(H.PolicyCharacterSurvival),
ChannelId: new ChannelId(H.Stamina_Current),
TargetChannelId:new ChannelId(H.Stamina_Max),
Threshold: 30,
Weight: 60),
};

Why this shape: Needs describe what the actor wants. They are declarative data (a channel reference, a target, a curve, a threshold, a weight). The keyword form gives the compiler a structural slot to emit; the underlying NeedRow (src/SECS.Schema/Rows/NeedRow.cs) is a flat positional record struct carrying only declaration metadata. The curve kind lives outside the row in a runtime registry, mirroring the formula-resolver pattern.

Needs live on the policy, never directly on the entity template. Settlements and characters are data; policies are behavior; mixing them confuses both. A single character may have multiple policies attached (Survival, Combat, Travel, Forage), each with its own needs relevant to its domain.

Curve kinds

SECS provides four built-in curves. Each maps (current, target, threshold) to [0, 1] urgency:

CurveShapeUse case
linearStraight line from 0 (at target) to 1 (at threshold).Even urgency rise (gold reserves, generic resources).
quadlinear². Sharper at the threshold.Scarcity-sensitive needs (food in famine).
inverse_quad1 - (1 - linear)². Smoother far from threshold, sharper at it."Low urgency for high HP, very high urgency near death" — the canonical survival curve.
sigmoidLogistic, centered at the midpoint between threshold and target.Step-like response (combat re-engagement gates).

The shipped scorer clamps both the normalized input and the curve output to [0, 1]. Curve evaluation is runtime-backed: INeedCurve (src/SECS.Runtime/Policies/Scoring/INeedCurve.cs) is the curve contract, six implementations live under src/SECS.Runtime/Policies/Scoring/Curves/LinearCurve, PolynomialCurve (integer exponent by repeated multiply; expresses the quad / inverse_quad shapes), StepCurve, and PiecewiseLinearCurve are exact-float, while LogisticCurve (the sigmoid shape) and LogitCurve route through transcendental functions and report IsDeterministicExact = false — and NeedCurveRegistry binds each NeedId to its curve, mirroring the formula-resolver pattern. NeedRow (src/SECS.Schema/Rows/NeedRow.cs) deliberately omits the curve: it is a runtime responsibility, not a schema column. The registry's default Register admits exact-float curves only; a determinism-hazard curve must be opted in explicitly through RegisterHazard, and ValidateCoverage cross-checks every declared need at boot.

Selectors and the utility-AI scorer

The utility-AI scorer ships under src/SECS.Runtime/Policies/Scoring/. PolicyDispatcherV2 resolves and scores CallBest when constructed with its opt-in scorer wiring (NeedCurveRegistry + SelectorResolver, both or neither); SecsRuntimeBuilder passes both once the host registers at least one INeedCurve through WithNeedCurve. SelectorSourceKind (src/SECS.Schema/Enums/SelectorSourceKind.cs) declares FromActor, AllRegistered, FromTags, and FromCollection. SelectorResolver (src/SECS.Runtime/Policies/Scoring/SelectorResolver.cs) resolves FromActor and AllRegistered directly and routes FromTags / FromCollection through the CandidateBuilderRegistry (host-registered via SecsRuntimeBuilder.WithCandidateBuilder), where an unregistered kind fails loud rather than yielding a silent empty set; the dispatcher's per-policy authorization currently rejects FromTags / FromCollection selectors loud until a candidate-builder path is wired into it. The shipped scoring core is IAUS (NeedScorer + CandidateArbiter): per-need normalize-and-curve considerations aggregated multiplicatively with the compensation factor, then a deterministic argmax with content-key tie-break and a zero score floor. The preview-projection scoring described in "How the scorer picks" below (preview reads, relevance, weighted contributions) is a design target with no runtime backing — read that pseudocode as specification, not as a description of shipped behavior.

selector Name { consider activity X from <source>; }

What: A candidate-source declaration. Selectors enumerate the activity pool for call_best to score and pick from. This contract uses the following source kinds:

  • from all_registered — match the selector's bound ActivityId against the registry. Used when the selector targets one specific activity (rest, food, etc.).
  • from tags = X, Y, Z — match every registered activity whose Tags contains at least one of the listed tag ids. Used for tagged candidate pools (food-providing items, healing items, defense-building activities).

How the scorer picks

For each candidate the executor calls activity.Preview(context) to read the candidate's EffectPlan, the unified preview surface. Then for each need on the policy:

current = channel_resolve(actor, need.channel)
target = ResolveNeedTarget(need)
urgency = need.curve(current, target, need.threshold)

if candidate has no preview or preview is empty:
relevance = 0 // unrelated: no info
else:
scan preview for channel-targeting effects matching need.ChannelId
if no match:
relevance = 0 // unrelated: touches different channels
else:
project current through matching effects via ApplyOpToProjection
projectedUrgency = need.curve(projected, target, need.threshold)
gain = max(0, urgency - projectedUrgency)
if gain > 0:
relevance = gain // improving
else:
relevance = 0.25 * urgency // matched-but-no-gain

contribution = need.weight * relevance
totalScore += contribution

The candidate with the highest score wins. The relevance heuristic has three cases:

  • Unrelated candidates score 0. An activity that does not touch a need's channel contributes nothing to that need's score. That includes both "no preview" and "preview but no matching effect" cases. Authors who want an idle-fallback option to rank above unrelated candidates must express that intent through an explicit preview row that touches the relevant channel with Add 0 (matched-but-no-gain ranks above unrelated).
  • Matched-but-no-gain candidates score 0.25 × urgency × weight. These are activities whose preview touches the need's channel but does not move the urgency curve (e.g. a +5 HP heal where the actor is already above the threshold, or a Modify row whose ModifierId is zero). Useful for tie-breaking against unrelated, but ranks below any gain.
  • Improving candidates score gain × weight. Both gain and 0.25 × urgency live in the same numeric space ([0, 1]), so they compose cleanly under the weight without further normalisation.

The compiler/registry static-analysis pass is intended to emit SECS0415 when a policy's selector can route to an activity that does not override Preview() — under this scoring contract that activity ranks unrelated against every need and is almost certainly an authoring oversight. Not built in v2: there is no policy/selector static-analysis pass in src/.

As a design target, hosts attach a PreviewDriftRecorder to ActivityExecutorV2 to capture per-run preview-vs-actual channel deltas. The recorder is opt-in (null = telemetry off, no overhead) and emits one PreviewDriftRecord per successful run. Not built in v2: ActivityExecutorV2 (src/SECS.Runtime/Activities/ActivityExecutorV2.cs) exposes no preview-recorder surface yet, and there is no EffectPlan preview path in src/.

Why the inversion matters

Templates declare what they DO (effects extracted from Preview). Policies declare what they WANT (needs). The executor multiplies them. There are no per-template AI weights; the AI weight of any option is computed from (effects × needs) at scoring time. This:

  • Scales — adding a new template, activity, or recipe-spawned item requires zero AI-side changes; effects + needs evaluate them automatically.
  • Composes with modifiers — a crafted potion with +HealAmount modifiers scores higher than a vanilla potion because the Preview pulls the modifier-stacked value through EffectPlan.
  • Allows central player tuning — the player adjusts a need's weight/threshold and every decision the policy makes shifts coherently.

Player-authored extensions

This section describes the slot contract for runtime rule, need, selector, and definition-reference edits.

Engine surface — ScopeSlotStore

The runtime stores per-actor slot lists in ScopeSlotStore (src/SECS.Runtime/Policies/ScopeSlotStore.cs) keyed by (EntityHandle actor, SlotKindId kind). The store is owned by PolicyDispatcherV2 and exposed through its Slots property; in v2 the slot list holds typed-id wrappers (RuleId, NeedId, SelectorId, TemplateId) — there is no managed RuleDeclaration payload in the slot. Each slot list is strongly typed at the API boundary, with the SlotKindId passed directly (there is no PolicySlotKinds accessor class in v2):

store.Initialize<RuleId>(actor, rulesKind, defaultRuleIds);
var ruleIds = store.Read<RuleId>(actor, rulesKind);
store.Append(actor, rulesKind, newRuleId);
store.Replace(actor, rulesKind, index, replacementRuleId);
store.RemoveAt<RuleId>(actor, rulesKind, index);

Hard invariants — the store throws on uninitialized reads (mutations require a prior Initialize), double initialization, type mismatch, and SlotKindId.None. There is no _or_create fallback; slots must be explicitly seeded at activation time. Not built in v2: no Valenar character-slot lowering exists yet under examples/valenar-v2/Generated/obj/**/SecsGenerated; a SeedDefaults host hook is the design target.

Runtime player rules — template<RuleSlot>

A policy defines compile-time rule defaults (RuleRow ids in declaration order). The host seeds the matching RuleIds into the actor's rules slot at activation time; runtime evaluation reads the RuleId list from the slot.

Slots MUST be seeded at activation time. There is no fall-through to the policy's compile-time defaults — a runtime read of an unseeded slot throws (mutations require a prior Initialize). Hosts call a SeedDefaults host hook (or the equivalent host bootstrap for non-character actors) right after the actor entity is activated. Not built in v2: the test seeding helper and the seed_from_policy slot lowering are design targets; no such helper exists in tests/.

// examples/valenar-v2/Content/characters/slots.secs
template<RuleSlot> on Character
{
seed_from_policy CharacterSurvival.Rules;
}

Design-target lowering (future compiler-owned examples/valenar-v2/Generated/obj/**/SecsGenerated/CharacterSlots.g.cs, not yet emitted). The slot holds typed-id wrappers, not managed declaration objects — seed the policy's RuleId / NeedId / SelectorId lists:

public static readonly SlotKindId Rules = new(H.Slot_PolicyRules);

public static void SeedDefaults(ScopeSlotStore store, EntityHandle actor, PolicyRow policy)
{
store.Initialize<RuleId>(actor, Rules, policy.RuleIds);
store.Initialize<NeedId>(actor, Needs, policy.NeedIds);
store.Initialize<SelectorId>(actor, Selectors, policy.SelectorIds);
store.Initialize<TemplateId>(actor, KnownSpells, Array.Empty<TemplateId>());
}

The final KnownSpells line is a Valenar-specific example of a game-owned definition-reference list; the generic slot model is simply "typed per-actor lists."

The live PolicyDispatcherV2.EvaluateTick walks the policy's declared rule rows in ascending Order; routing the walk through each actor's seeded rule slots is the design target (the store is owned by the dispatcher and exposed through Slots, and rule handlers can read it through RuleEvaluationContext, but the rule walk itself does not read it yet). Under the slot-driven design target, edits to the slot (insert, replace, remove) take effect on the next rule walk — no cache, no dirty bit, no synchronization step.

Runtime player needs — template<NeedSlot>

The need slot stores NeedIds; the design target enriches the underlying need declaration so the bare target-channel field is replaced with a NeedTargetExpr discriminated union, letting designers and runtime authoring tools pick between literal, channel-resolved, and formula-driven satiation targets. Not built in v2: the committed v2 row is NeedRow (src/SECS.Schema/Rows/NeedRow.cs) with fields (NeedId Id, PolicyId OwnerPolicyId, ChannelId ChannelId, ChannelId TargetChannelId, double Threshold, double Weight) — a single channel-ref target, no NeedTargetExpr, no curve field, no name. The enriched form below is the design target:

// DESIGN TARGET — v2 ships the flat NeedRow above; this enriched
// declaration is not yet backed.
public sealed record NeedDeclaration(
NeedId Id,
string Name,
ulong ChannelId,
NeedTargetExpr Target,
NeedCurveKind Curve,
int Threshold,
int Weight);

public abstract record NeedTargetExpr
{
public sealed record Literal(int Value) : NeedTargetExpr;
public sealed record ChannelRef(ulong ChannelId) : NeedTargetExpr;
public sealed record Formula(ulong FormulaId) : NeedTargetExpr; // formula-backed target
}

Same slot pattern: the design-target scorer reads the need slot from the actor's slot store; an unseeded read throws. Not built in v2: the shipped CallBest scorer reads the policy's declared NeedRow range from the schema, not the actor's need slot, so player-added needs do not yet participate in scoring.

Definition-reference slots and collection-backed selectors

A policy may also read a typed per-actor list of definition references for a content family. The generic rule is the same one used throughout the behavior docs: content definition data stores row-specific knobs, one generic activity implements the mechanic, and typed args carry the chosen definition reference into each ActivityRequest.

The runtime bridge that turns those stored references into candidates is part of the C# execution boundary, not standalone .secs vocabulary. This doc stays at the design level; 10-host-secs-execution-boundary.md § 17.3-17.4 documents the current runtime types and registration path.

Valenar example: CharacterSlots.KnownSpells stores TemplateId values pointing at SpellDefinition templates. The runtime bridge packages each stored spell definition id into CastSpellArgs and emits a generic Activity_CastSpell request. Adding a new spell definition is therefore a data change, not a new activity declaration.

Predicted post-values — Set, Multiply, Modify

The design-target candidate-relevance pass resolves the actor's current value via ChannelResolverV2.Resolve(EntityHandle, ChannelId) (which returns double; there is no ResolveIntFast in v2) and projects each effect. Not built in v2: no effect-projection pass exists in src/; the shipped NeedScorer reads channel values but does not project candidate effects.

PredictionOpProjection
Addprojected + Amount
SetAmount
Multiplyprojected * (Amount / 100f) (matches EffectPlanner's percent-scaled long encoding)
Modifyprojected + Amount * (stacks + 1) where stacks = resolver.BindingStore.CountBindings(effect.Target, effect.ModifierId); a row with ModifierId == 0 contributes nothing (the candidate still scores as "touches the channel")

Effects compose left-to-right within the same EffectPlan, so an Add +20 followed by Multiply 200% yields a projected value of 40 on top of a current value of 0.

Save / load and migration

v2 ships a unified SecsSavePayload (docs/runtime/rn-save-load-determinism.md) composed of a KernelSaveSection (entities, relations, modifier bindings, saved scopes, pending journal entries) and a SecsRuntimeSaveSection (PRNG, current tick, cumulative counters, pending choices, active activity runs, policy active children, and per-actor mutable policy slot contents). Per-actor mutable policy slot contents persist through SecsRuntimeSaveSection.PolicySlots (PolicySlotSaveRow rows produced by ScopeSlotStore.SnapshotAll and rehydrated by ScopeSlotStore.RestoreFrom), and PolicyDispatcherV2's active-children map persists through SecsRuntimeSaveSection.PolicyActiveChildren (PolicyActiveChildSaveRow rows produced by SnapshotActiveChildren and rehydrated by RestoreActiveChildren). The distinction between host-owned world data and SECS-owned runtime records is preserved:

StateCurrent statusFuture target / gap
Settlement / character entity datav1: Host-owned entity store; not a SECS runtime payload. v2: Same — entity identity flows through KernelSaveSection.Entities (EntitySaveRow), but scope-field host data lives in the host's world container.Host world snapshot/container persists scope fields, collections, and entities before SECS runtime records are restored.
Policy entity (for policy-attached state)v1/v2: Host-owned entity data if the game models policies as entities.Same host world snapshot path as any other entity; not part of SecsSavePayload.
Need tunable params (Need_*_Threshold, Need_*_Weight)v1/v2: Host-owned channel backing / scope-field data when a game exposes tunables.Persist through the host world snapshot; SECS validates definitions after load.
Player rule slotsv1: ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicyRules). v2 implemented: persisted via ScopeSlotStore.SnapshotAllPolicySlotSaveRow rows in SecsRuntimeSaveSection.PolicySlots; RestoreFrom rehydrates on a fresh store. RuleId entries widen through the SlotElementTypeTag.UInt64 codec branch. Distinct from KernelSaveSection.SavedScopes (blittable saved-scope frames), which encode a different data class.None — covered by SecsRuntimeSaveSection.PolicySlots in v2.
Player need slotsv1: ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicyNeeds). v2 implemented: persisted via the same PolicySlotSaveRow path; NeedId entries widen through the SlotElementTypeTag.UInt64 codec branch.None — covered by SecsRuntimeSaveSection.PolicySlots in v2.
Player selector slotsv1: ScopeSlotStore rows keyed by (EntityHandle actor, SlotKindId Slot_PolicySelectors). v2 implemented: persisted via the same PolicySlotSaveRow path; SelectorId entries widen through the SlotElementTypeTag.UInt64 codec branch.None — covered by SecsRuntimeSaveSection.PolicySlots in v2.
Valenar known-spell listv1: ScopeSlotStore row keyed by (EntityHandle actor, SlotKindId Slot_Character_KnownSpells), with TemplateId[] entries for SpellDefinition templates. v2 implemented: persisted via the same PolicySlotSaveRow path; TemplateId entries widen through the SlotElementTypeTag.UInt64 codec branch.None — covered by SecsRuntimeSaveSection.PolicySlots in v2.
Active activity runsv1: ActivityRun.ToState() produces ActivityRunState; restore seeds ActivityExecutor before calling ActivityRunStore.Restore(activity, state, durationTicks). Mismatches throw SECS0810/SECS0811/SECS0812. v2 implemented: SecsRuntimeSaveSection.ActiveRuns carries ActivityRunRecord (Id, ActivityId, Actor, Target, StartedAtTick, CompletedAtTick, Args) at src/SECS.Runtime/Activities/ActivityRunRecord.cs:9; Args is an ActivityArgsBlob with SecsTypeRef plus canonical structured payload bytes.None — covered by SecsRuntimeSaveSection.ActiveRuns in v2.
Slot contents (rules, needs, selectors, known-spells)v1: ScopeSlotStore.Snapshot() returns IReadOnlyList<SlotSnapshot> with Type ElementType and managed IReadOnlyList<object> payload; Restore(snapshots) runs on a fresh store. Hard invalidation on type mismatch (SECS0821) or unknown slot kind / non-empty store (SECS0820). v2 implemented: ScopeSlotStore.SnapshotAll emits deterministic PolicySlotSaveRow rows (ascending EntityHandle, then SlotKindId) into SecsRuntimeSaveSection.PolicySlots; RestoreFrom rehydrates them on a fresh store with hard invariants (non-empty store, null actor, SlotKindId.None, unknown SlotElementTypeTag, and duplicate (actor, kind) pairs all throw). The element codec is a closed blittable set (SlotElementTypeTag: UInt64 / Int64 / Int32); the live rule / need / selector / known-spell slots all hold typed-id wrappers and persist through the UInt64 branch.Remaining limit (not a gap in the live slots): a slot opened with an element type outside the closed SlotElementTypeTag set throws at SlotList<T>.Snapshot rather than persisting silently. Widening the codec to managed declaration objects is the documented forward-compatible move on SlotElementTypeTag.
PolicyDispatcherV2.activeChildrenv1: PolicyDispatcher.activeChildren in-memory dictionary; not persisted. Child run tracking is lost on save/load. v2 implemented: PolicyDispatcherV2 holds a live (PolicyId, EntityHandle) → ActivityRunId active-children map; SnapshotActiveChildren emits PolicyActiveChildSaveRow rows into SecsRuntimeSaveSection.PolicyActiveChildren and RestoreActiveChildren rehydrates them, cross-checking each row's ActivityRunId against the restored active-run list and rejecting stale entries loud. Restore runs after ActiveRuns.None — covered by SecsRuntimeSaveSection.PolicyActiveChildren in v2.

Slot persistence is per-actor, per-slot-kind. The conceptual on-disk shape of a single slot row is { EntityHandle actor, SlotKindId kind, T[] entries } where T is the slot's payload type (e.g. RuleDeclaration, NeedDeclaration, SelectorDeclaration, TemplateId). Restore rehydrates each row by invoking store.Initialize<T>(actor, kind, entries) on a fresh ScopeSlotStore. There is no ScopedList<RuleSlot> collection on the policy entity — slot data hangs off the actor, never off the policy.

When the game updates and policy/activity/need definitions change between the saved game's version and the current build:

ChangeMigration policy
Compile-time rule addedEnabled by default; no save-side migration
Compile-time rule removedSave's enabled flag for that rule dropped (no-op)
Compile-time need addedTunable params get default values
Compile-time need removedTunable params dropped from save
RuleSlot's TargetActivityId references a removed activitySlot disabled, UI surfaces warning, slot retained for player to fix
NeedSlot's ChannelId references a removed channelSame — disabled with warning
Policy itself removedActive policy run cancelled, retained slots discarded
Activity signature changed (e.g. now requires a target it didn't before)In-flight ActivityRun invalidated, parent policy resumes from the rule that dispatched it

End-to-end example

A character survival policy demonstrating the complete flow.

1. Authored definitions (compile time)

// examples/valenar-v2/Content/policies/character_survival.secs
policy CharacterSurvival
{
actor Character;
domain Survival;

need StayAlive
{
channel = HP_Current;
target = HP_Max;
curve = inverse_quad;
threshold = 50;
weight = 100;
}

need KeepFed
{
channel = Stamina_Current;
target = Stamina_Max;
curve = inverse_quad;
threshold = 30;
weight = 60;
}

selector RestSelector
{
consider activity RestAtCamp from all_registered;
}

rule Decision EmergencyRest()
{
if (actor.resolve(HP_Current) * 4 < actor.resolve(HP_Max))
return call_best(RestSelector);
return continue;
}
}

2. Game runtime — host evaluates the policy

v2: policies are declared via WorldSchema rows consumed by SecsRuntimeBuilder
(per docs/runtime/rn-external-engine-adapters.md § Boot Contract); the
tick pipeline runs a PolicyDispatch step every tick.

PolicyDispatcherV2.EvaluateTick(tick):
for each actor whose contract root scope matches PolicyRow.ActorScopeId:
walk the policy's rule rows in ascending Order:
decision = ruleHandlers.Invoke(ruleId, in RuleEvaluationContext)
switch (decision.Kind):
case Continue: move to the next rule
case Complete: stop walking, return PolicyDispatchResult.Complete
case Fail: stop walking, return PolicyDispatchResult.Fail
case Call: ActivityExecutorV2.Start(request, tick) // suppressed
if the same lane already has an active child
case Wait: stop walking, return PolicyDispatchResult.Wait
(start nothing; leave the active child untouched)
case CallBest: resolve selector candidates (SelectorResolver),
score each against the policy's needs (NeedScorer),
start the argmax winner through the Call path
case CancelChild:cancel the tracked child run (firing OnCancel),
then advance to the next rule

The call_best(RestSelector) rule in the authored example above lowers to a CallBest decision that PolicyDispatcherV2 resolves through SelectorResolver, scores through NeedScorer, and dispatches through the same start path as a literal call.

3. Player edits the policy at runtime

The runtime path looks like:

The slot store is owned by PolicyDispatcherV2 (its Slots property); the slot holds typed-id wrappers, not managed declaration objects:

At MainCharacter activation (design-target host hook):
CharacterSlots.SeedDefaults(dispatcher.Slots, character, policyRow);
→ store seeded with policy.RuleIds, policy.NeedIds, policy.SelectorIds,
empty Valenar KnownSpells list

At runtime (player edits):
dispatcher.Slots.Replace<NeedId>(character, needsKind, index, editedNeedId);
→ the design-target scorer would read the edited need on its next pass
(the shipped CallBest scorer reads the policy's declared NeedRow range
from the schema; per-actor need slots are not yet routed into scoring)

At runtime (Valenar spell example):
dispatcher.Slots.Append<TemplateId>(character, knownSpellsKind, new TemplateId(H.Spell_TestSpell));
→ the design-target collection-backed selector would then see one
additional spell definition reference and produce a CastSpell candidate
carrying that template id in typed args (selector source FromCollection
routes through the CandidateBuilderRegistry in SelectorResolver, but
the dispatcher's per-policy authorization does not yet accept
FromTags / FromCollection selectors, and no shipping policy drives
those kinds).

The client-facing wire surface is a read/edit API on top of ScopeSlotStore. policy.catalog describes the authored policy defaults; per-actor edits are represented as slot read/write operations and surfaced by the policy-edit panel.

Execution notes

  1. Multi-policy-per-actor concurrency. One active policy run is allowed per domain at a time for a given actor. Re-entrancy is defined per policy kind.
  2. Sub-call failure semantics. PolicyDispatcherV2 (src/SECS.Runtime/Policies/PolicyDispatcherV2.cs) is the reference loop. When RuleDecision.Call(...)'s underlying ActivityExecutorV2.Start cannot start the activity (the same lane already has an active child) the dispatcher returns PolicyDispatchResult.Fail. Call creates a policy-origin ActivityRequest. CallBest resolves candidates, scores them, and dispatches the argmax winner through the same start path as a literal Call, inheriting the same double-start suppression, lane-occupancy, and active-child tracking semantics. See § Policy dispatch loop and 04-behavior.md § Policy dispatch loop for the full decision table.
  3. Rule ordering. Rules are walked in ascending Order; Complete, Fail, and Wait terminate the walk, while Call / CallBest / CancelChild take effect and advance (PolicyDispatcherV2 implements this). The utility scoring ranks selector candidates, not rules.
  4. Need curves. The runtime curve surface is INeedCurve with six implementations under src/SECS.Runtime/Policies/Scoring/Curves/ (LinearCurve, PolynomialCurve, StepCurve, PiecewiseLinearCurve exact-float; LogisticCurve, LogitCurve determinism-hazard opt-in), bound per NeedId through NeedCurveRegistry. NeedRow omits the curve by design (see § Curve kinds).
  5. Predicted-change evaluation. The design-target candidate scoring resolves the actor's current value via ChannelResolverV2.Resolve(EntityHandle, ChannelId) (returns double; no ResolveIntFast) and projects each effect left-to-right. Add, Set, Multiply, and Modify use the projection rules defined in § Predicted post-values. Not built in v2: no effect-projection pass exists in src/; the shipped scorer evaluates needs without projecting candidate effects.
  6. Preview source of truth. EffectPlan is the design-target unified preview surface for the AI; there is no separate analyzer-owned preview path. Not built in v2: no EffectPlan preview path exists in src/.
  7. Save-versioning and player commands. Engine-wide migration callbacks and player-facing commands such as Command("policy.set_channel", ...), Command("policy.add_rule_slot", ...), and Command("scope.set_field", ...) sit above this policy contract and must preserve the slot semantics defined here.

Static analysis (warn-only)

The design target is a warn-only pass that walks every declared policy and reports patterns that almost never reflect designer intent — never throwing, every diagnostic carrying Severity.Warning. Not built in v2: there is no warn-only policy static-analysis pass in src/. The v2 validation entry is WorldSchema.Validate() (called by SecsRuntimeBuilder at construction, src/SECS.Schema/WorldSchema.cs), which performs hard FK validation that throws — it is not the warn-only diagnostic surface below. The diagnostics codes here are the design target — the reserved SECS0910-SECS0914 band in SECS-Compiler-Plan.md § Diagnostic Code Catalog, with no shipped owner:

CodeWhat fires itWhy warn-only
SECS0910policy need references an unknown channel ida need targeting an unregistered channel will silently score zero forever; usually a typo or a forgotten channel declaration
SECS0911policy selector references an activity id with no declared activitythe selector returns no candidates at scoring time; load order may legitimately declare activities after policies so this is warn-only
SECS0912two needs over the same channel have weights of opposite signthe contributions cancel at scoring time; same-sign duplicates are allowed (multiple urgency curves on one channel is a legitimate design)
SECS0913SelectorSourceKind.FromTags references an unknown tagthe selector enumerates an empty tag set forever
SECS0914policy actor scope id has no declared scopemod patches can rewrite ActorScopeId and bypass the per-policy hard-check

The design-target pass cross-references declared activity ids (so declaration order matters: declare activities before policies that point at them, or accept the warning as a known-late-binding signal).

Relationship to existing SECS docs

  • 04-behavior.md § Policies covers the lowering contract for the policy keyword — declaration shell, generated PolicyRow shape, PolicyDispatcherV2 runtime API, declaration through SecsModule.BuildSchema(). This doc covers the AI scoring math and philosophy; the lowering doc covers the C# emission contract.
  • 03-channels-and-modifiers.md defines the modifier stack. Need tunable parameters are channels and participate in the modifier pipeline — a status effect can stack a modifier onto a need's weight to shift the policy's priorities globally.
  • 06-overrides-and-modding.md § 8.8–8.9 defines the activity / policy slot schema. Activity architectural slots (activity_actor_scope, activity_target_scope, activity_lane, activity_args_type_ref) and policy architectural slots (policy_actor_scope, policy_domain) are replace-only. Inject may patch ordinary activity fields such as duration, cooldown, metadata, tags/cost lists, lifecycle bodies, and the preview body; policy inject may patch or append needs, selectors, rules, and rule evaluate bodies by child id. Slot identity follows the structured SlotKey(DeclarationKind, DeclarationId, SlotKind, ChildId?) shape from § 7.3 of that document.
  • 08-collections-and-propagation.md defines ScopedList<T>, used for player-authored PlayerRules and PlayerNeeds.

Scope boundary

  • Specific game-side AI content stays game-owned. Combat policy, settlement governance, forage/rest behavior, and dungeon orchestration are Valenar or per-game authoring decisions, not engine surface.
  • Multi-actor coordination is out of scope for this contract. The current policy model is single-actor; squad plans or cross-actor goal sharing would require a separate primitive.
  • Long-horizon planning is also out of scope. The selector scores immediate options; GOAP-style multi-step planning would sit above this layer.