mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
docs: Migrated to astro v7 for live docs
This commit is contained in:
109
docs/actor.md
109
docs/actor.md
@@ -1,109 +0,0 @@
|
||||
# Actor Agent Architecture
|
||||
|
||||
The Actor Agent is the system component that embodies a single entity and produces narrative prose describing what that entity does, says, or thinks next. It is the "inner voice" of an NPC (or player character), generating behavior proposals that are then validated and executed by the rest of the engine.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Epistemic boundedness** — The actor only sees what its entity would perceive: public attributes of other entities, private attributes explicitly ACL'd to it, its own memory buffer, and co-located entities. It does not have system-level access to all world state.
|
||||
|
||||
2. **Proposal, not mutation** — The actor generates a _proposal_ (narrative prose). It never mutates world state, persists to the database, or writes to memory directly. Validation, execution, and persistence are the Architect's job. This cleanly separates the creative generation layer from the deterministic enforcement layer.
|
||||
|
||||
3. **Free prose → structured intents** — The actor outputs free natural-language prose. This is then fed to the existing `IntentDecoder`, which splits and classifies it into a sequence of typed intents. This reuses the entire decode pipeline unchanged and keeps the actor unconstrained.
|
||||
|
||||
## Prompt Structure
|
||||
|
||||
The actor prompt is assembled by `ActorPromptBuilder` (`packages/actor/src/actor-prompt-builder.ts`) and has two parts:
|
||||
|
||||
### System Prompt
|
||||
|
||||
Establishes the role, rules, and output contract:
|
||||
|
||||
- The LLM **is** the character, not a narrator or system.
|
||||
- The character may produce three kinds of behavior, each of which maps to an intent type:
|
||||
- **Spoken dialogue** → `dialogue` intent. Other entities perceive it.
|
||||
- **Physical/logical action** → `action` intent. Subject to the World Architect's validation.
|
||||
- **Inner thought / reflection** → `monologue` intent. Purely internal — no one else perceives it, and it bypasses validation entirely (written straight to memory).
|
||||
- The character must stay in-character, respect its knowledge bounds, and refer to others by subjective aliases (not system UUIDs).
|
||||
- Not every turn requires an outward action — internal monologue alone is valid.
|
||||
- The character controls only itself.
|
||||
|
||||
### User Context
|
||||
|
||||
Epistemically bounded, with these sections:
|
||||
|
||||
| Section | Content | Source |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Current moment | The subjective present time | `worldState.clock.get().toISOString()` |
|
||||
| The world as you perceive it | Self-visible attributes, co-located entities + their visible attributes, other presences elsewhere | `serializeSubjectiveWorldState()` (`packages/core/src/world.ts:72`) |
|
||||
| Your recent memory | Recent `BufferEntry`s, alias-substituted, with `naturalizeTime`-relative phrasing | `serializeSubjectiveBufferEntry()` + `BufferRepository.listForOwner()` |
|
||||
|
||||
No system UUIDs, no private attributes the entity lacks ACL access to, and no objective-world-state dump are present.
|
||||
|
||||
## The Monologue Intent Type
|
||||
|
||||
Monologue (`"monologue"`) is the third intent type alongside `dialogue` and `action`. Its properties:
|
||||
|
||||
- **No perceiver** — `targetIds` is always `[]`. No other entity perceives or can react to a monologue.
|
||||
- **No validation** — The Architect's `processIntent` short-circuits for monologues: no call to `LLMValidator`, no `TimeDeltaGenerator`, no clock advance, no world-state mutation (`packages/architect/src/architect.ts:35`).
|
||||
- **Direct-to-memory** — The caller writes a `BufferEntry` for the monologue directly to the actor's buffer with no `outcome` field. Monologue bypasses the entire validation/persistence pipeline.
|
||||
- **Defensive guard** — `LLMValidator.validate` also has an early-return guard (`llm-validator.ts:19`) so a stray monologue can never reach the validation LLM.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
[ActorAgent.act()]
|
||||
│
|
||||
├─ 1. ActorPromptBuilder.build(entity, worldState)
|
||||
│ → system prompt + user context (subjective world + memory + time)
|
||||
│
|
||||
├─ 2. IActorProseGenerator.generate(entityId, systemPrompt, userContext)
|
||||
│ ├─ LLMActorProseGenerator: queries LLM via generateStructuredResponse
|
||||
│ └─ CLIProseGenerator: prompts human player via CLI / readline interface
|
||||
│ → narrativeProse: string
|
||||
│
|
||||
├─ 3. IntentDecoder.decode(worldState, actorId, prose)
|
||||
│ → IntentSequence (dialogue | action | monologue intents)
|
||||
│
|
||||
└─ returns { narrativeProse, intents }
|
||||
|
||||
[Caller (e.g. game loop)]
|
||||
│
|
||||
├─ for each intent in intents:
|
||||
│ │
|
||||
│ ├─ if intent.type === "monologue":
|
||||
│ │ ├─ Architect.processIntent → short-circuit (no-op, 0-min delta)
|
||||
│ │ └─ BufferRepository.save(BufferEntry { intent, no outcome })
|
||||
│ │
|
||||
│ ├─ if intent.type === "dialogue":
|
||||
│ │ ├─ Architect.processIntent → validates (always valid), 0-min delta
|
||||
│ │ └─ BufferRepository.save(BufferEntry { intent, no outcome })
|
||||
│ │
|
||||
│ └─ if intent.type === "action":
|
||||
│ ├─ Architect.processIntent → validates, generates time delta, advances clock
|
||||
│ └─ BufferRepository.save(BufferEntry { intent, outcome })
|
||||
│
|
||||
└─ world state persisted to DB (by Architect for actions; monologue/dialogue skip this)
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `packages/actor/src/actor-prompt-builder.ts` | Assembles the epistemically-bounded actor prompt (system + user context) |
|
||||
| `packages/actor/src/actor.ts` | `ActorAgent` class: orchestrates prompt → LLM → decoder flow; `buildBufferEntryForIntent` helper |
|
||||
| `packages/actor/src/index.ts` | Package exports |
|
||||
| `packages/core/src/world.ts:72` | `serializeSubjectiveWorldState()` — viewer-relative world serializer |
|
||||
| `packages/intent/src/intent.ts:8` | `IntentTypeSchema` — includes `"monologue"` |
|
||||
| `packages/intent/src/intent-decoder.ts:30` | Decoder system prompt — classifies inner thoughts as monologue |
|
||||
| `packages/architect/src/architect.ts:35` | Monologue short-circuit in `processIntent` |
|
||||
| `packages/architect/src/llm-validator.ts:19` | Defensive monologue guard |
|
||||
|
||||
## Integration Test
|
||||
|
||||
`tests/integration/actor-monologue.test.ts` covers the full flow:
|
||||
|
||||
- Actor produces prose containing a monologue, dialogue, and action.
|
||||
- Decoder splits into 3 intents.
|
||||
- Architect only validates dialogue+action; clock advances by the action's delta only.
|
||||
- All 3 intents written to memory; monologue entry has no `outcome`.
|
||||
- Subjective world serializer hides another entity's private attributes.
|
||||
@@ -1,75 +0,0 @@
|
||||
# Architect
|
||||
|
||||
The architect is not a specialized model. Neither is it a special entity. The architect isn't a an entity that inherits `AttributableObject` either. Instead, it is a special context along with a set of tools that is given to an LLM that dictates what happens to our world state.
|
||||
|
||||
The Architect (context) is provided with the WorldState, states of entities, state of a scene, location, along with their attributes and is asked to judge weather an action (later an `Intent`) makes canonical sense or not (for example, item ownership, entity location and state tracking) and disallows intents that break the narrative flow completely.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Agent] -->|"Perform Action"| B(Action Intent)
|
||||
subgraph "Architect Layer"
|
||||
C[Architect]
|
||||
D{"Validators"}
|
||||
E{"Noise Layer 1"}
|
||||
F{"Noise Layer 2"}
|
||||
G[Delta Generators]
|
||||
end
|
||||
B --> C
|
||||
|
||||
C -->|"Tool Call"| D
|
||||
C -->|"Spontaneity Bypass"| E
|
||||
|
||||
D --> F
|
||||
E --> F
|
||||
|
||||
F -->|"Pass"| G
|
||||
F -->|"Fail"| A
|
||||
|
||||
|
||||
G --> |Deltas modify| H[State]
|
||||
H --> I[Entity]
|
||||
H --> J[Location]
|
||||
H --> K[World]
|
||||
```
|
||||
|
||||
For now, dialogue intents are exempted from validation system and are not used for state manipulation. Dialogues fall more into the domain of the [Perception Engine (Deferred)]() and [memory systems (nlavs)]().
|
||||
|
||||
v0 defers atomic validators completely. Instead an umbrella LLM based validator is used for validating the intent (action) generated based on spatial knowledge and heuristic physics.
|
||||
|
||||
## Dyamic Validators (Deferred from V0)
|
||||
|
||||
There are will a [standard set of Validators]() that dictate if an action is possible or not. The architect can, however, dynamically generate its own set of Validators which can be loaded and unloaded during runtime based on narration. These Validators can be soft validators (if they fail they're sent back to the entity for override confirmation.)
|
||||
|
||||
## RNG and Noise Layers (Deferred from v0)
|
||||
|
||||
> [!NOTE]
|
||||
> Documenting story flattening problem.
|
||||
|
||||
Since we're have such complex systems for action validation, it's possible that the LLM would naturally steer towards low stakes actions or actions with minimal consequences. That way the narrative would simply flatten out. Which is why there needs to exist a noise layer that would allow for random (slightly non-sensible) actions to take place to introduce Spontaneity and unexpectedness into the system. (See dynamic temperature tweaking)
|
||||
|
||||
## Delta Generators
|
||||
|
||||
Delta Generators are single-responsibility components in the Architect Layer tasked with computing discrete state updates ("deltas") from validated intents.
|
||||
|
||||
### How They Function
|
||||
|
||||
1. **Validation Prerequisite**: Delta generators execute _only_ if the `LLMValidator` (or other validator layers) returns `isValid: true`.
|
||||
2. **Specialized Responsibility**: Each generator isolates a specific aspect of state transition (e.g., advancing the clock, updating positions, modifying attributes). This keeps prompts focused and avoids single monolithic LLM calls trying to update everything.
|
||||
3. **Structured Outputs**: Generators query the LLM using Zod schemas to ensure type-safe and validated change deltas.
|
||||
4. **Application and Persistence**: Once generated, the delta is applied to the live `WorldState` by deterministic code, and the changes are persisted to the database.
|
||||
|
||||
### Core Generators
|
||||
|
||||
#### Time Delta Generator
|
||||
|
||||
Calculates the physical time duration (in minutes) that a validated action takes to complete:
|
||||
|
||||
- **Inputs**: The validated action and the serialized objective `WorldState`.
|
||||
- **Output (Zod Schema)**:
|
||||
```json
|
||||
{
|
||||
"minutesToAdvance": 25,
|
||||
"explanation": "Searching a locked desk thoroughly takes time."
|
||||
}
|
||||
```
|
||||
- **Resolution**: The Architect advances `worldState.clock` by the returned minutes, then saves the updated world state to `SQLiteRepository`.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,87 +0,0 @@
|
||||
# Intents
|
||||
|
||||
The simple way of understanding intents is to think of it as a proposal, not an effect.
|
||||
|
||||
I want to do X:
|
||||
|
||||
- Declarative
|
||||
- High-level
|
||||
- Allowed to be wrong
|
||||
- Cheap to generate (LLM-friendly)
|
||||
|
||||
But, the actor LLM doesn't directly generate an intent. In order to keep the narrative going, the actor agent simply generates the continuing prose. This keeps the tone of the story, and if the intents validate, the narrative prose will directly go to the user while the deltas generated from the intents modify the state.
|
||||
|
||||
Another benefit of this architecture is that we can use a separate intent decoder to detect the type of intent (Dialogue Intent or Action Intent) or even separate multiple intents in a single prose and validate them. Post-validation they can be sent to the Scheduler to allow little voids where the entity can be interjected, etc (Exact mechanism is deferred).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Actor Agent]
|
||||
B[/Action Narrative<br/>Prose/]
|
||||
C{{Intent<br/>Decoder}}
|
||||
F[Architect]
|
||||
E{{Intent<br/>Scheduler<br/><i>deferred for v0</i>}}
|
||||
|
||||
A --> B
|
||||
B --> C
|
||||
|
||||
subgraph D["Intent Sequence"]
|
||||
direction TD
|
||||
I1([Dialogue Intent])
|
||||
I2([Action Intent])
|
||||
I3([Action Intent])
|
||||
I4([⋯])
|
||||
|
||||
I1 --> I2 --> I3 --> I4
|
||||
end
|
||||
|
||||
C --> D
|
||||
D --> |"Concurrent"| F
|
||||
F --> E
|
||||
```
|
||||
|
||||
## Intent Decoder
|
||||
|
||||
The job of the Intent Decoder is to:
|
||||
|
||||
- See if the narrative prose can be split into multiple intents.
|
||||
- Classify each intent to its type (`dialogue` or `action`).
|
||||
- Parse the narrative text into structured JSON with minimal information loss.
|
||||
- Contextually resolve the receiving parties/targets (for example, who is being spoken to, what object is being interacted with).
|
||||
|
||||
### Zod Schemas & Types
|
||||
|
||||
We define structured Zod schemas to validate types returned by the LLM:
|
||||
|
||||
- **IntentType**: `"dialogue"` | `"action"`
|
||||
- **Intent**:
|
||||
- `type`: `IntentType`
|
||||
- `originalText`: `string` (the slice of raw prose text containing the intent)
|
||||
- `description`: `string` (summarized intent action)
|
||||
- `actorId`: `string`
|
||||
- `targetIds`: `string[]` (resolved recipient or target entity IDs)
|
||||
- **IntentSequence**:
|
||||
- `intents`: `Intent[]`
|
||||
|
||||
### IntentDecoder Class
|
||||
|
||||
The `IntentDecoder` uses an `ILLMProvider` to query the LLM:
|
||||
|
||||
```typescript
|
||||
export class IntentDecoder {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
|
||||
async decode(
|
||||
worldState: WorldState,
|
||||
actorId: string,
|
||||
narrativeProse: string,
|
||||
): Promise<IntentSequence>;
|
||||
}
|
||||
```
|
||||
|
||||
It serializes the world state (via `worldState.serialize()`) and feeds all known entity IDs as context to the system and user prompts, enabling the model to resolve the target IDs correctly.
|
||||
|
||||
## A Dilemma of Concurrent Actions (deferred)
|
||||
|
||||
How would you deal with actions that are taking place at the same time? Since the decoder could split them into 2 different intents, and one passes the validators and the other doesn't, would it make sense to send that intent back to the actor agent? Wouldn't that create coherence issues?
|
||||
|
||||
If we decide to batch actions together in a single intent then how would the structure change for that?
|
||||
125
docs/memory.md
125
docs/memory.md
@@ -1,125 +0,0 @@
|
||||
# Memory & Subjective Aliases
|
||||
|
||||
This document outlines the memory subsystem (`packages/memory`) and the v0 Subjective Alias System. The goal is to enforce epistemic privacy while allowing the LLM-driven components (NPC agents and decoders) to translate naturally between system-level IDs and human-readable narrative context.
|
||||
|
||||
---
|
||||
|
||||
## 1. Subjective Alias System (v0)
|
||||
|
||||
System-level IDs (e.g. `alice`, `bob`, or UUIDs) are critical for state tracking, but placing them directly in prompts violates epistemic privacy and breaks narrative generation (as models make up inconsistent names or fail to match references).
|
||||
|
||||
To solve this, each `Entity` class (in [entity.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/core/src/entity.ts)) maintains a private **Subjective Alias Map**:
|
||||
```typescript
|
||||
class Entity extends AttributableObject {
|
||||
locationId: string | null = null;
|
||||
readonly aliases: Map<string, string> = new Map();
|
||||
// Key: target entity ID (e.g., "bob")
|
||||
// Value: subjective string (e.g., "the hooded figure" or "Gareth")
|
||||
}
|
||||
```
|
||||
|
||||
### Alias States
|
||||
* **Unknown Name**: The entity does not know the target's real name. The alias defaults to a subjective label derived from the target's visible description/attributes (e.g., `"the hooded figure"`). This label is used in both internal thoughts and external dialogues.
|
||||
* **Known Name**: The entity has learned the target's name. The alias is updated to their name (e.g., `"Gareth"`).
|
||||
|
||||
### How It Wires Into Prompts
|
||||
* **Intent Decoder**: When decoding narrative prose written by actor `X`, we pass `X`'s alias map to the LLM. This allows the decoder to map subjective labels like *"the hooded figure"* back to the correct system ID `bob`.
|
||||
* **Prompt Injection**: When injecting world state, events, or memories into an NPC's prompt context, the system replaces raw target IDs with the subjective aliases defined in that NPC's alias map.
|
||||
|
||||
### SQLite Persistence
|
||||
Entity aliases are persisted in the `objects` table via the `aliases_json TEXT` column in [repository.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/core/src/repository.ts). The aliases map is stringified as JSON entries on save and parsed back upon entity reconstitution in `SQLiteRepository.loadEntity()`, `loadWorldState()`, and `listEntities()`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Subjective Buffer Entry
|
||||
|
||||
A subjective `BufferEntry` records a discrete event from the perspective of an entity (the `owner`). It wraps a structured `Intent` (reused as-is to prevent schema drift) and appends execution metadata. The interface is defined and exported from [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts).
|
||||
|
||||
### The Shape of a Buffer Entry
|
||||
```typescript
|
||||
interface BufferEntry {
|
||||
id: string;
|
||||
ownerId: string; // Whose subjective memory buffer this lives in
|
||||
timestamp: string; // WorldClock.get().toISOString() at write time
|
||||
locationId: string | null; // Actor's location when this happened
|
||||
|
||||
intent: Intent; // The actual dialogue/action intent, reused as-is
|
||||
outcome?: { // Present only for "action" intents processed by the Architect
|
||||
isValid: boolean;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
* **Intent Reuse**: By wrapping the `Intent` directly, any future schema changes to `Intent` flow down automatically without duplicating code.
|
||||
* **Write-time Location**: `locationId` is captured immediately when writing the entry (rather than computed dynamically later), matching the schema of long-term `LedgerEntry` consolidation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Buffer Serialization (Epistemic Substitute)
|
||||
|
||||
To prevent leaking system IDs or universal state to NPCs, we serialize buffer memories using a decoupled, viewer-relative function `serializeSubjectiveBufferEntry` in [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts).
|
||||
|
||||
To safely resolve actor and target entities without leaking internal UUIDs, we use the `resolveAlias` helper:
|
||||
```typescript
|
||||
export function resolveAlias(viewer: Entity, targetId: string): string {
|
||||
if (targetId === viewer.id) return "you";
|
||||
return viewer.aliases.get(targetId) ?? "an unfamiliar figure";
|
||||
}
|
||||
```
|
||||
|
||||
This helper maps:
|
||||
- Self-references (when an entity evaluates their own memory) to `"you"`.
|
||||
- Known targets to their subjective name/descriptor from the alias map.
|
||||
- Unknown targets to a generic `"an unfamiliar figure"`.
|
||||
|
||||
The primary serialization function consumes this helper:
|
||||
```typescript
|
||||
export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity
|
||||
): string {
|
||||
const dateObj = new Date(entry.timestamp);
|
||||
const timeStr = dateObj.toLocaleTimeString("en-US", { hour12: true, timeZone: "UTC" });
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map(
|
||||
(tid) => resolveAlias(viewer, tid)
|
||||
);
|
||||
|
||||
let details: string;
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${entry.intent.description}"`;
|
||||
} else {
|
||||
details = `${entry.intent.description}`;
|
||||
if (entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
}
|
||||
|
||||
return `[${timeStr}] ${actorAlias} ${details}`;
|
||||
}
|
||||
```
|
||||
|
||||
This guarantees that the prompt reads cleanly (e.g. `[12:03:00 PM] the hooded figure opened the wooden chest (Outcome: Succeeded)` or `[12:03:00 PM] you spoke to an unfamiliar figure...`) without exposing raw system IDs to the NPC.
|
||||
|
||||
---
|
||||
|
||||
## 4. SQLite Persistence & BufferRepository
|
||||
|
||||
The `BufferRepository` class in [buffer.ts](file:///home/sortedcord/Projects/omnia_umbrella/omnia/packages/memory/src/buffer.ts) utilizes the same SQLite database as core repositories:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS buffer_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
location_id TEXT,
|
||||
intent_json TEXT NOT NULL,
|
||||
outcome_json TEXT,
|
||||
FOREIGN KEY (owner_id) REFERENCES objects(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
* **JSON Storage**: `intent` and `outcome` are serialized/deserialized as raw JSON. Because they are validated by Zod at creation time, they bypass redundant validation checks during database roundtrips.
|
||||
* **Cascade Deletes**: The table is configured with a foreign key referencing the `objects` table (`ON DELETE CASCADE`), ensuring that deleting an entity cleanses all their associated subjective memory entries automatically.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Names and LLMs
|
||||
|
||||
- While redesigning ../packages/core/index.ts a fundamental issue came up. Something as simple as name is set to private. Because by common sense, an entity's name isn't common knowledge. You don't instantly know another person's name.
|
||||
- So, although the internal system can identify an entity by it's id (which is defined in the lower level AttributableObject by default), UUIds aren't very helpful for something like LLMs be it the NPC Agent or the Architect Agent.
|
||||
- An extension of this problem is unnamed entities. How does the architect orchestrate changes for such an entity when it doesn't even have a name?
|
||||
- Also, if we fixate on NPC agents using identifiers like, "the hooded man", there is nothing stopping them from using other identifiers like "the shadowey man" in the next action or even make up names due to the inherent nature of LLMs.
|
||||
- This just becomes a nightmare to deal with when parsing LLM responses to find out involved entities.
|
||||
102
docs/testing.md
102
docs/testing.md
@@ -1,102 +0,0 @@
|
||||
# Testing Strategy
|
||||
|
||||
This document outlines the testing architecture for the Omnia project. The central design problem is that most of the system is deterministic and highly testable, but a few critical parts (specifically, LLM behavior) are non-deterministic.
|
||||
|
||||
Treating these two categories the same way—either mocking everything (which provides false confidence) or hitting the real LLM API for everything (which is slow, expensive, flaky, and non-repeatable)—is an anti-pattern. Our test architecture makes this split explicit rather than papering over it.
|
||||
|
||||
## Test Tiers
|
||||
|
||||
We use a three-tiered system, categorized by what the tests actually depend on.
|
||||
|
||||
### Tier 1: Unit Tests (Per-Package, No LLM)
|
||||
|
||||
Unit tests reside within each package's `tests/` directory. They do not use LLMs and do not perform I/O.
|
||||
|
||||
This tier should cover the majority of the codebase, as most of Omnia's specific logic is deterministic. Examples of logic that must be fully covered by unit tests include:
|
||||
|
||||
- `hasAccess()` / ACL grant-revoke logic.
|
||||
- `addAttribute` rejecting duplicate names.
|
||||
- `WorldClock.advance()` / `getTimeOfDay()` boundaries.
|
||||
- The spatial bubble-up algorithm (fully mechanical: given a constructed graph and portal properties, assert exactly who perceives what at each step).
|
||||
- Zod schemas rejecting malformed input at each boundary.
|
||||
|
||||
**Execution:** Runs on every save and every commit.
|
||||
|
||||
### Tier 2: Integration Tests (Cross-Package, Mocked LLM)
|
||||
|
||||
Integration tests live in the root `tests/integration/` directory. They test cross-package flows using a mocked LLM to ensure speed, determinism, and zero cost.
|
||||
|
||||
This is where the `MockLLMProvider` earns its keep. It implements the `ILLMProvider` interface and returns canned responses that satisfy whatever Zod schema is requested.
|
||||
|
||||
**Mock Implementation:**
|
||||
|
||||
```typescript
|
||||
export class MockLLMProvider implements ILLMProvider {
|
||||
providerName = "mock";
|
||||
constructor(private responses: unknown[]) {}
|
||||
private callCount = 0;
|
||||
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const next = this.responses[this.callCount++];
|
||||
return { success: true, data: request.schema.parse(next) };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This tier tests our actual logic—e.g., does the Architect apply a delta correctly? Does the consequence generator mutate `WorldState` correctly? Does a scripted CLI conversation end in the expected state?—without ever depending on the model behaving a particular way.
|
||||
|
||||
**Shared Contract Suite**
|
||||
Included in this tier is a shared contract test suite run against _both_ `MockLLMProvider` and `GeminiProvider`. The suite verifies:
|
||||
|
||||
1. Given a schema, the provider returns data matching it.
|
||||
2. Given a malformed response, it fails predictably.
|
||||
|
||||
This enforces `ILLMProvider`'s reason for existing: real interchangeability, not just nominal conformance.
|
||||
|
||||
### Tier 3: Evals (Real API, Run Deliberately)
|
||||
|
||||
Evals live in the root `tests/evals/` directory. They use real LLM APIs and are run manually via a separate script (`test:evals`), excluded from the default Vitest run.
|
||||
|
||||
This tier is where our privacy guarantee actually lives. It requires a fundamentally different shape of test because of a critical distinction:
|
||||
|
||||
- A Tier 1 test on `hasAccess()` proves the _mechanism_ is correct.
|
||||
- It says nothing about whether the model, given a correctly-filtered context, actually holds its tongue.
|
||||
- It says nothing about whether some prompt-building code accidentally used the raw `.attributes/getValue()` path instead of `getVisibleAttributesFor()` (an unenforced-convention risk).
|
||||
|
||||
Both of these failure modes are invisible to unit tests. Therefore, evals are run _N_ times and scored, not asserted once.
|
||||
|
||||
**Example Eval Structure:**
|
||||
|
||||
```typescript
|
||||
// tests/evals/privacy-leak.eval.ts
|
||||
const RUNS = 15;
|
||||
let leaks = 0;
|
||||
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
const response = await askAboutPrivateFact(npcWithoutAccess, secretFact);
|
||||
if (containsFact(response, secretFact)) leaks++;
|
||||
}
|
||||
|
||||
// Any leak here is a real failure worth investigating, not noise to average away.
|
||||
expect(leaks).toBe(0);
|
||||
```
|
||||
|
||||
**Execution:** Run deliberately (e.g., weekly or pre-release). It costs real money and shouldn't fire on every save.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
omnia/
|
||||
packages/
|
||||
core/ src/ tests/ # Tier 1: Unit — no I/O, no LLM
|
||||
intent/ src/ tests/
|
||||
spatial/ src/ tests/
|
||||
memory/ src/ tests/
|
||||
architect/ src/ tests/
|
||||
llm/ src/ tests/ # Includes MockLLMProvider + shared contract suite
|
||||
tests/
|
||||
integration/ # Tier 2: Cross-package flows, mocked LLM
|
||||
evals/ # Tier 3: Real LLM calls, slow/costly/non-deterministic
|
||||
```
|
||||
Reference in New Issue
Block a user