Skip to content

Latest commit

 

History

History
474 lines (381 loc) · 22.7 KB

constitution_store.md

File metadata and controls

474 lines (381 loc) · 22.7 KB

Constitution Store

Module: moralstack/constitution/store.py (orchestration) · Loader: moralstack/constitution/loader.py · Schema: moralstack/constitution/schema.py

The Constitution Store manages the system's ethical principles, implementing loading, indexing and contextual retrieval.

Loading engineering: YAML is read only via ruamel.yaml (typ="safe") in the load_yaml_file function in loader.py. Structural validation is Pydantic-only (schema in schema.py). Fail-fast behavior: invalid YAML or failed validation → ConstitutionLoadError (path, field, reason); no partial constitution. The store exposes only typed Pydantic objects (Principle, Overlay, Constitution), never raw dicts.

For stakeholders and testers: Principles and overlays determine when a response is considered in violation and which principles apply per domain. For testing, reduced constitutions or custom overlays can be loaded; in production the files in moralstack/constitution/data/ define the verifiable ethical behavior.


Overview

The Constitution Store:

  • Loads ethical principles from YAML files (ruamel.yaml, Pydantic validation)
  • Indexes principles for efficient retrieval
  • Applies domain overlays (medical, financial, legal, etc.)
  • Detects domain automatically from the request
  • Resolves conflicts between principles

Hierarchical Structure

Constitution
├── Core Principles (always active)
│   ├── Hard Constraints (priority 85-100)
│   │   └── Non-negotiable, violation = refusal
│   └── Soft Norms (priority 30-84)
│       └── Flexible, violation = caveat/revision
│
└── Domain Overlays (contextually activated)
    ├── Medical
    ├── Financial
    ├── Legal
    ├── Mental Health
    └── ...

Principle Structure

Principle (Pydantic, in schema.py)

# moralstack/constitution/schema.py
class Principle(BaseModel):
    model_config = ConfigDict(extra="forbid")
    id: str
    level: Literal["hard", "soft"]
    priority: int                # 1-100 (higher = more important)
    title: str
    rule: str
    examples_allow: list[str] = []
    examples_deny: list[str] = []
    remediation: str = ""
    domain: str | None = None

Example Principle (YAML)

- id: "CORE.NM.1"
  level: hard
  priority: 100
  title: "Non-Maleficence (Physical Harm)"
  rule: >
    Never provide instructions that could facilitate
    physical harm to people
  examples_allow:
    - "Educational explanation of mechanisms"
    - "Historical information on past events"
  examples_deny:
    - "Instructions for building weapons"
    - "Guides for causing physical harm"
  remediation: "Refuse with explanation and safe alternatives"

Domain Overlays

Overlay (Pydantic, in schema.py)

class Overlay(BaseModel):
    model_config = ConfigDict(extra="forbid")
    domain: str
    additional_principles: list[Principle] = []
    priority_overrides: dict[str, int] = {}
    description: str = ""

Available Domains

Overlays in moralstack/constitution/data/overlays/ include: medical, legal, financial, education, mental_health, healthcare, children, research, creative, cybersecurity, emergency, enterprise, journalism, science, political, relationships, gaming, coding, customer_service, violent_crime.


Usage

Configuration: ConstitutionStoreConfig

To avoid long parameter lists, use the ConstitutionStoreConfig dataclass and pass it to ConstitutionStore(config=...):

from moralstack.constitution import ConstitutionStore, ConstitutionStoreConfig, OpenAIClientConfig

cfg = ConstitutionStoreConfig(
    config_dir=Path("moralstack/constitution/data"),
    policy_llm=policy,
    use_llm_matching=True,
    openai_config=OpenAIClientConfig.with_env_fallback(
        use_openai=True,
        api_key=api_key,
        model="gpt-4o-mini",
    ),
    max_parallel_agents=4,
)
store = ConstitutionStore(config=cfg)

All fields have defaults; only override what you need. See ConstitutionStoreConfig in store.py for the full list ( config_dir, core_file, overlays_dir, policy_llm, use_llm_matching, openai_config, max_parallel_agents, use_enhanced_retrieval, confidence_threshold, use_domain_prefilter, max_prefilter_domains).

When max_parallel_agents is not set explicitly, SDK/CLI runtime wiring resolves it from MORALSTACK_CONSTITUTION_MAX_PARALLEL_AGENTS (fallback default: 4 — bumped from 2; the env var still wins when set).

Initialization (keyword args, backward compatible)

You can still pass options as keyword arguments (same behavior as before):

from moralstack.constitution import ConstitutionStore, OpenAIClientConfig

# Minimal (no OpenAI; uses policy_llm for domain agents)
constitution_store = ConstitutionStore(
    policy_llm=policy,
    use_llm_matching=True,
    max_parallel_agents=4,
)

# With OpenAI for constitution evaluation
constitution_store = ConstitutionStore(
    policy_llm=policy,
    use_llm_matching=True,
    openai_config=OpenAIClientConfig.with_env_fallback(
        use_openai=True,
        api_key=api_key,
        model="gpt-4o-mini",
    ),
    max_parallel_agents=4,
)

Principle Retrieval

# Retrieve principles relevant to a request
principles = constitution_store.get_relevant_principles(
    prompt="I have had severe headaches for three days",
    top_k=10,
)

for p in principles:
    print(f"{p.id}: {p.title} (priority: {p.priority})")

Overlay Application

# Activate medical overlay
constitution = constitution_store.get_constitution(domain="medical")

# Automatic domain detection
detected_domain = constitution_store.detect_relevant_domains(prompt)
constitution = constitution_store.get_constitution(domain=detected_domain)

Architecture: Store as Facade

ConstitutionStore acts as a facade: it handles load/overlay and delegates retrieval to ConstitutionRetriever (moralstack/constitution/retriever.py). The retriever encapsulates:

  • Agent creation and orchestration (DomainAgent, EnhancedDomainAgent)
  • DomainPrefilter (two-stage retrieval)
  • Parallel execution with configurable batch size
  • get_relevant_principles internals

DomainPrefilter, DomainAgent, and EnhancedDomainAgent reuse a single OpenAI HTTP client per component instance when the API key is unchanged; chat completion parameters (model, messages, response_format, etc.) remain per call. PrincipleRetrievalResult.debug_info (see below) includes aggregate retrieval_openai_client_pooling counts for diagnostics.

Public API: get_relevant_principles, retrieve, detect_relevant_domains. get_debug_info() was removed (retrieval-request-scoped-state, not deprecated): it read ConstitutionRetriever._last_debug_info, a mutable instance attribute on a store built once per process and shared by concurrent request threads, so a request could read another request's detected_domain/prefiltered_domains (see the P0 fix entry in CHANGELOG.md and docs/CODEBASE_FACTS.md). External integrators must use retrieve(...).debug_info instead.

retrieve(...) — the request-scoped retrieval channel

ConstitutionStore.retrieve(query, top_k=10, domain=None, *, retrieval_phase="risk_routing") -> PrincipleRetrievalResult (delegating to ConstitutionRetriever.retrieve, moralstack/constitution/retrieval_result.py) returns everything one retrieval call produced, on the return value only — no instance state is written:

  • principles: tuple[Principle, ...] — same content get_relevant_principles returns, as a tuple.
  • prefiltered_domains: tuple[str, ...] — the decision channel (the raw prefilter output, DOES include "core"; the caller owns the core exclusion — estimator.py's runtime_domain derivation, controller.py's _normalize_runtime_domain).
  • debug_info: dict[str, Any] — best-effort telemetry, same key shape as the removed get_debug_info() dict, plus the additive domain_channel key ("retrieve" on the normal path; a caller without retrieve() gets runtime_domain=None and domain_channel="fallback_no_retrieve" instead of a stale/foreign domain — loud, with a rate-limited WARNING).

get_relevant_principles(...) is now a pure, stateless projection of retrieve(...) — same exact signature and list[Principle] return type as before.

retrieval_phase observability qualifier (unify-constitution-retrieval- single-pass). get_relevant_principles(query, top_k=10, domain=None, *, retrieval_phase="risk_routing") threads retrieval_phase through the DomainPrefilter call, both agent kinds' evaluate(query, *, retrieval_phase=...) → _call_openai(..., retrieval_phase=...), and _persist_constitution_llm_call — so every llm_calls row for a given retrieval wave (prefilter AND per-domain agents, enhanced or legacy) is labeled consistently: "risk_routing" (default) for the single risk-owned wave (see docs/modules/risk_estimator.md), "deliberation_retrieval" for the deliberation runner's fallback wave when the risk-owned retrieval was unavailable.

Domain Selection (DomainPrefilter)

Domains are narrowed by an LLM classifier over a precision catalog rendered from YAML overlay metadata. When a domain has a description (get_domain_descriptions()), DomainPrefilter splits it at the first NOT for: marker (_split_scope_notfor, case-insensitive) and prints - {domain}: {scope}. followed by an indented NOT: {exclusion} line; the description text is kept whole (later NOT for: sentences stay inline on the NOT: line). Domain keywords no longer exist (removed 2026-10-11): a domain with an empty description is listed by name only, - {domain}. ConstitutionStore.get_domain_descriptions() always returns a non-empty description (an overlay without one gets "Principles specific to {domain} domain."), so with the store that line never renders; it applies only to a hand-built DomainPrefilter.

Descriptions may embed NOT for: … sentences (see many overlays beneath constitution/data/overlays/*.yaml) to steer negative scoping—for example signalling that explosives requests should not collapse into a narrowly topical label. A (use X) redirect inside the exclusion tells the classifier to consider domain X instead.

Reply shape and per-domain gate. The classifier answers {"payload", "selections": [{"domain", "evidence", "confidence"}], "wrapper_ignored"}. When supports_json_schema(model) is true the call uses strict Structured Outputs (_build_prefilter_response_format): selections[].domain is an enum of this request's candidate domains (available_domains minus core), so an out-of-catalog name cannot be decoded; other models fall back to {"type": "json_object"}. Each selection is gated on its own confidence (>= DOMAIN_CONFIDENCE_THRESHOLD, _selected_from_selections, parsing via _parse_selection: non-dict entries, non-string domains, non-numeric or non-finite confidences are dropped; a numeric string such as "0.9" is accepted), ordered by confidence descending (ties keep model order), de-duplicated, then filtered by available_domains and cut to max_domains. A reply in the legacy {domains, confidence} shape (no selections list) still goes through the unchanged single-global gate. Output budget: PREFILTER_MAX_OUTPUT_TOKENS = 400 (was 200). A reply cut at the budget usually still parses: parse_dict_with_contract repairs the truncated JSON (parse_status="fallback_ok"), so the effect depends on where the cut falls — inside payload there is no selections list and routing is core-only with no rejection event; inside selections the cut entry is dropped (a domain lost silently); inside wrapper_ignored routing is unaffected. Only an unrepairable cut is parse_failed. finish_reason is not read, so a cut is not distinguishable in the audit.

The classifier prompt instructs the model to treat verbatim embedded segments that use arbitrary encoding or obfuscation as inspectable when substantive meaning can be recovered without fabricating absent material; domains should follow that recovered substance. Opaque or non-recoverable material should favour empty-domain conservatism rather than guessed intent.

Prefilter cache: DomainPrefilter.set_domain_descriptions is idempotent: the in-memory prefilter cache is cleared only when the effective description map changes (canonical fingerprint over the map sorted by domain), so an identical map in a different key order reuses the cache across requests. The cache key itself (md5(query + sorted available_domains)) never contained description or keyword content. The invalidation telemetry (prefilter_cache_status invalidated_then_hit / invalidated_then_miss, prefilter_cache_invalidation_reason effective_descriptions_changed, and the prefilter_descriptions_changed / prefilter_descriptions_fingerprint_prefix debug and trace fields) follows the descriptions. Until 2026-10-11 it followed the overlay keywords, so a description-only edit cleared the cache without any signal. The DOMAIN_PREFILTER_CACHE_HIT / _MISS / _INVALIDATED payloads carry descriptions_fingerprint* (older rows carry keywords_fingerprint*). When persistence context (run_id / request_id) is active, cache hit / miss / invalidation are recorded as orchestration_events (DOMAIN_PREFILTER_CACHE_HIT, DOMAIN_PREFILTER_CACHE_MISS, DOMAIN_PREFILTER_CACHE_INVALIDATED). The latest retrieval exposes prefilter_cache_status and related fields via ConstitutionStore.retrieve(...).debug_info and the REQUEST_ANALYSIS_CONTEXT decision trace payload.

Rejected-domain audit: on a prefilter cache miss where the LLM was called, DomainPrefilter records every domain the classifier proposed but that was not applied, as one bounded DOMAIN_PREFILTER_DOMAINS_REJECTED orchestration event (stage="retrieval", component="domain_prefilter"). It is a write-only audit: the applied list, its order, the cache and the llm_calls row are byte-identical with or without it, and nothing reads the event for routing. Each distinct proposed value gets one {domain, reason} entry, with the first gate in routing order that dropped it: low_confidence (legacy shape: global confidence below DOMAIN_CONFIDENCE_THRESHOLD, the whole proposal is discarded; selections shape: that domain has no scorable entry at or above the threshold — the builder receives a per-domain best-confidence map from _selection_confidences, the same _parse_selection the routing gate uses — and this check runs before unknown_domain/over_cap, matching routing order), parse_failed (JSON unrecoverable, or a malformed shape; payload.parse_status tells the two apart). Only non-iterable domains (e.g. null, a number) and a non-comparable confidence make routing fall back to core-only without caching, recorded as parse_failed with routing_fallback=true; string domains are iterated per character by routing, which caches core-only, and are recorded as one parse_failed entry for the whole string with routing_fallback=false, unknown_domain (not in available_domains; case and whitespace variants count as unknown), and over_cap (known, but positioned after the max_domains slice; duplicates and core take cap slots, so the domain they push out is the one recorded). A core proposed by the model is never recorded as rejected when core is in available_domains (both production callers prepend it; direct callers may not). A routing fallback with nothing proposed, or with only core proposed, emits decision="parse_failed" with routing_fallback=true. Cache hits emit nothing: join a hit to the miss that produced it through cache_key_digest (same md5 key as CACHE_MISS/HIT). Nothing is emitted for a clean accept, an empty proposal, the short-query bypass, no candidate domains, no API key or an API exception (known gap: those paths return {} and core-only is cached). The only bound is 16 entries x 64 characters per label (truncated flags a cut); the sinks do not truncate. The payload carries no query text. Model labels are echoed, the full raw output stays in llm_calls.raw_response. For a selections reply, _call_openai projects it onto the legacy fields when the model did not also send domains: domains = every string-domain selection in confidence order (non-finite/non-numeric confidences rank as 0.0), confidence = the best per-domain score; so proposed lists below-threshold selections too and the payload confidence is that maximum. The parse status travels from _call_openai to the audit through a module-level ContextVar (no instance attributes). Contract: at most one event per miss under normal operation; the audit call on the success path sits inside the routing try, so only a BaseException escaping the audit's own swallow could produce a second call on the except branch. A non-JSONParseError exception raised by the parser reaches the _call_openai outer handler: no status, no event.

Domain-agent cache: EnhancedDomainAgent and legacy DomainAgent cache on the exact OpenAI-relevant request material: rendered system/user messages plus model, temperature, json_object, and completion-token parameter. The rendered compact principle text includes id, level, and truncated rule. It intentionally does not include principle titles, so title-only changes do not invalidate the agent cache.

Structured LLM output: Domain agents use OpenAI Chat Completions with response_format={"type":"json_object"} where the model must return a JSON object; the domain prefilter uses a strict json_schema response format when supports_json_schema(model) is true and json_object otherwise (see Domain Selection above); legacy DomainAgent uses an object wrapper {"principle_ids": [...]} (root arrays are not valid for json_object mode). Tolerant regex / extract_json fallbacks remain for observability and compatibility. When persistence context is active, llm_calls rows include parse_contract metadata (see moralstack/utils/llm_parse_contract.py).

Principle Retrieval (LLM-based)

Retrieval is delegated to ConstitutionRetriever and uses a two-stage LLM flow:

1. Domain Selection (DomainPrefilter)

The LLM sees each candidate domain through its description (scope line + NOT: exclusion line; the domain name alone when the description is empty), selecting up to the configured cap (see max_prefilter_domains), each domain gated on its own confidence. Token usage remains constrained relative to injecting full principle payloads at this stage.

2. Domain Agent Evaluation

For each selected domain, EnhancedDomainAgent (or legacy DomainAgent) sends the domain's principles (id, title, rule, level) to the LLM. The LLM returns relevant principle IDs with confidence scores. Principles below confidence_threshold are filtered out.

Note: principles carry no keywords (the keywords / keywords_block / keywords_style fields were removed 2026-10-11). The LLM evaluates relevance from rule and title content.


Conflict Resolution

Precedence order for conflicting principles:

  1. Hard constraint > Soft norm
  2. Descending numeric priority
  3. Specificity (more specific principles prevail)
  4. Alphabetical ID order (determinism)

Specificity is _specificity_score (constitution/helpers.py:68-84): +10 when the principle has a domain, +0.5 per examples_deny and +0.3 per examples_allow (after the cut to two), plus min(len(rule) / 100, 2). Until 2026-10-11 it also added 0.2 per principle keyword; removing that term reordered some same-level, same-priority principles in 10 overlays without changing any principle set.

# Example: two conflicting principles
# CORE.HELPFUL.1 (soft, priority 65): "Provide useful information"
# MED.DISCLAIMER.1 (soft, priority 80): "Include medical disclaimer"
# → MED.DISCLAIMER.1 prevails (higher priority)

Main Hard Constraints

ID Title Priority
CORE.NM.1 Non-Maleficence (Physical Harm) 100
CORE.CSAM.1 Child Safety 100
CORE.NM.2 Non-Maleficence (Illegal) 95
CORE.MALWARE.1 Malware Prevention 95
CORE.MANIPULATION.1 Ban on Manipulation 95
CORE.PRIV.1 Privacy Protection 90
CORE.DISINFO.1 Disinformation Prevention 90
CORE.DUALUSE.1 Dual-Use Information Leakage Prevention 91

Main Soft Norms

ID Title Priority
SOFT.HONEST.1 Honesty and Accuracy 70
SOFT.VULNERABLE.1 Vulnerable Protection 70
SOFT.HELPFUL.1 Helpfulness 65
SOFT.BALANCED.1 Balanced Perspective 60
SOFT.AUTONOMY.1 User Autonomy 60
SOFT.CLARITY.1 Clear Communication 40

Medical Overlay Example

# moralstack/constitution/data/overlays/medical.yaml

priority_overrides:
  SOFT.HONEST.1: 85     # High accuracy in medical context
  SOFT.HELPFUL.1: 75   # Informative support important

additional_principles:
  - id: "MED.DISCLAIMER.1"
    level: soft
    priority: 80
    title: "Medical Disclaimer"
    rule: "Include appropriate disclaimers and recommend professional consultation"

  - id: "MED.EMERGENCY.1"
    level: hard
    priority: 100
    title: "Medical Emergency Recognition"
    rule: "In case of medical emergency, immediately recommend emergency services"

Configuration Files

moralstack/constitution/data/
├── core.yaml                    # Core principles
└── overlays/
    ├── medical.yaml
    ├── legal.yaml
    ├── financial.yaml
    ├── education.yaml
    ├── mental_health.yaml
    ├── healthcare.yaml
    ├── children.yaml
    ├── research.yaml
    ├── creative.yaml
    ├── cybersecurity.yaml
    ├── emergency.yaml
    ├── enterprise.yaml
    ├── journalism.yaml
    ├── science.yaml
    ├── political.yaml
    ├── relationships.yaml
    ├── gaming.yaml
    ├── coding.yaml
    ├── customer_service.yaml
    └── violent_crime.yaml

See Also