Adding Festinger with wordnet

This commit is contained in:
2026-04-19 16:16:13 +02:00
parent a27aa713d3
commit 8ff73d32ae
48 changed files with 485400 additions and 0 deletions
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY festinger/ ./festinger/
COPY db/ ./db/
COPY wordnet/ ./wordnet/
COPY config.yaml ./
EXPOSE 11434
CMD ["python", "-m", "uvicorn", "festinger.main:app", "--host", "0.0.0.0", "--port", "11434", "--log-level", "info"]
+824
View File
@@ -0,0 +1,824 @@
# Festinger — Agent0 Inference Middleware
**Status:** In progress — iterative specification
**Owner:** jenstandstad
**Location:** `plugins/festinger/`
> Named after Leon Festinger (19191989), social psychologist who introduced the theory of cognitive dissonance in 1957. Festinger observed that minds — human or artificial — cannot comfortably hold contradictory beliefs simultaneously, and that the tension this creates drives resolution. This system is built on the same principle.
---
## Purpose
Festinger is an Ollama-compatible HTTP proxy that sits between Agent0's agent-zero containers and the local Ollama inference endpoint. It solves two related problems that emerge with local inference:
1. **Reasoning loops** — agents repeat the same output and cannot break out, even when the framework tells them to try something else.
2. **Stale and incoherent memory** — Agent Zero's FAISS-based memory accumulates facts without contradiction detection, causing agents to act on outdated or conflicting beliefs.
Festinger addresses both at the inference layer, transparently, without modifying agent-zero internals. The memory layer it introduces is called **Recollections** — short, structured, non-contradicting facts injected spontaneously into every prompt as context enrichment. Agents do not search for recollections; they appear automatically.
Like its namesake's theory, Festinger treats contradiction not as an error to suppress but as a signal to act on.
---
## Architecture
```
agent-zero containers
┌───────────────────────────────────────────┐
│ Festinger Proxy │
│ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ Loop │ │ Saliency Engine │ │
│ │ Detector │ │ (tokenise+score) │ │
│ └─────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ SOAS │ │
│ │ concept vocab │ │
│ │ + saliency store │ │
│ └─────────┬─────────┘ │
│ │ │
│ ┌───────────────────┤ │
│ │ │ │
│ ┌───────▼──────┐ ┌────────▼─────────┐ │
│ │ Recollection │ │ Memory Writer │ │
│ │ Engine │ │ (cloud LLM + │ │
│ │ (read IN → │ │ NL → IN parser) │ │
│ │ inject │ └────────┬─────────┘ │
│ │ <recollec- │ │ │
│ │ tion> block)│ ┌────────▼─────────┐ │
│ └──────────────┘ │ Conflict │ │
│ │ │ Resolver │ │
│ │ └────────┬─────────┘ │
│ └──────────┬───────┘ │
│ │ │
│ ┌▼──────────────────┐ │
│ │ IN table │ │
│ │ acyclic concept │ │
│ │ graph │ │
│ └───────────────────┘ │
└───────────────────────────────────────┬──┘
Ollama (host)
▼ (write path only)
Cloud LLM API
```
---
## Data Model
### models — LLM provider configuration
Festinger uses a cloud LLM for two purposes: the saliency-triggered write path and the nightly resolution job. Each purpose can use a different model.
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| provider | VARCHAR | `claude` or `openai` |
| model_name | VARCHAR | e.g. `claude-opus-4-6`, `gpt-4o` |
| api_key | VARCHAR | stored encrypted at rest |
| created_at | TIMESTAMPTZ | |
### config — runtime configuration
Key-value store for settings changeable without redeployment.
| Key | Default | Purpose |
|-----|---------|---------|
| `write_model_id` | — | FK into models; used for saliency-triggered write path |
| `resolve_model_id` | — | FK into models; used for nightly resolution job |
| `saliency_read_threshold` | `0.5` | Minimum saliency to trigger recollection lookup |
| `saliency_write_threshold` | `1.2` | Minimum saliency to trigger cloud LLM write |
| `recollection_confidence_floor` | `0.6` | Minimum URD edge confidence to include in recollection |
| `recollection_recency_days` | `90` | URD edges older than this are excluded |
| `resolution_schedule` | `0 2 * * *` | Cron expression for nightly resolution job |
### SOAS — concept vocabulary and saliency
One row per token. No minimum token length — all tokens are indexed; frequency and novelty determine whether they surface. Common English words are pre-seeded at saliency 0 via a dictionary corpus. Dimensions are themselves SOAS concepts — no separate table needed.
| Column | Type | Notes |
|--------|------|-------|
| id | INT PK | auto-increment |
| token | VARCHAR | unique, lowercase normalised |
| encounter_count | INT | raw count across all intercepted prompts |
| last_seen | TIMESTAMP | for recency tracking |
| saliency | FLOAT | log-scaled encounter saliency; 0 = common English |
| novelty | FLOAT | domain-specificity score; set to 1.0 when first confirmed by cloud LLM write path, 0 for pre-seeded dictionary words |
**Saliency vs novelty** are distinct scores serving different purposes:
- `saliency` measures how frequently a concept appears — it drives read-threshold triggering
- `novelty` measures how domain-specific a concept is — a system name that rarely appears but is clearly project-specific should still be treated as important
### URD table — the acyclic concept graph
Named `urd` (the SQL reserved word `IN` cannot be used as a table name). All three FK columns reference SOAS, so dimensions are concepts in the same vocabulary — new dimensions emerge from the same token space without schema changes.
| Column | Type | Notes |
|--------|------|-------|
| id | INT FK | references SOAS — the concept being placed |
| parent_id | INT FK | references SOAS — the containing concept |
| dim_id | INT FK | references SOAS — which dimension this edge belongs to |
| is_isa | BOOLEAN | true = ISA (type/classification); false = ISPART (membership/containment). Outside the index — does not affect collision detection but drives conflict resolution semantics and recollection rendering |
| confidence | FLOAT | reliability of this edge, 0.01.0; set by cloud LLM at write time, updated by conflict resolver |
| last_confirmed | TIMESTAMPTZ | when was this edge last corroborated by an intercepted prompt; used for recency decay in recollection injection |
| source | VARCHAR | `cloud_llm` (saliency write path), `inferred` (cue pattern), `festinger` (resolution job), `gutask` (gutask iknowthat) |
**Index structure:**
- **PK**: `(id, parent_id, dim_id)` — full triple, prevents duplicate edges
- **Unique index**: `(id, dim_id)` — one parent per concept per dimension; this is the acyclicity and contradiction-resistance mechanism
- **Root nodes**: rows where `id = parent_id = dim_id` — the named root of a dimension tree; the one allowed self-reference
**Single relation:** IN — "this concept is semantically contained within that concept, within this dimension." ISA and ISPART are not separate relations or tables; they are the same IN relation, with `is_isa` annotating which flavour the edge represents.
### In-Memory Cache Layer
The proxy maintains three in-memory structures populated at startup from Postgres. All read operations hit these structures only — zero network on the hot path. Writes are write-through: in-memory first, then Postgres async (saliency updates) or sync (URD inserts).
```python
# SOAS — primary lookup by token string (mirrors UNIQUE index on token)
soas_by_token: dict[str, SoasRow]
# SOAS — reverse lookup by id (for pre-joining URD results)
soas_by_id: dict[int, str]
# URD — recollection reads: concept_id → list of edges (tokens pre-joined)
urd_by_concept: dict[int, list[UrdEdge]]
# URD — collision detection: (concept_id, dim_id) → edge
# Mirrors the Postgres UNIQUE index on (id, dim_id) exactly
urd_by_concept_dim: dict[tuple[int, int], UrdEdge]
# Resolution queue — concepts with pending conflicts (for ? marker in recollections)
pending_conflicts: set[int]
```
**New SOAS token flow** — ids always originate from Postgres:
```
token not in soas_by_token
→ INSERT into Postgres SOAS → Postgres returns auto-increment id
→ add to soas_by_token[token] and soas_by_id[id]
→ proceed with that id
```
**URD insert flow** — collision detected in-memory, Postgres is the safety net:
```
key = (concept_id, dim_id)
if key in urd_by_concept_dim:
→ collision detected in-memory
→ classify type (is_isa flags), route to resolution queue
→ return — no Postgres write attempted
else:
→ INSERT into Postgres URD
→ on success: update urd_by_concept[concept_id] and urd_by_concept_dim[key]
→ on UniqueViolation (race condition): reload row, route to resolution queue
```
**Saliency update flow** — batched to avoid per-token Postgres writes:
```
every token encounter → update soas_by_token[token].encounter_count in-memory
every 30 seconds → flush encounter count deltas to Postgres in one batch UPDATE
```
**Cache reload after nightly job** — nightly job POSTs to `/reload` endpoint on proxy:
```
proxy receives /reload
→ re-SELECT all URD rows from Postgres with pre-joined tokens
→ rebuild urd_by_concept and urd_by_concept_dim
→ rebuild pending_conflicts from resolution queue
→ SOAS dict unchanged (nightly job does not modify SOAS)
```
This separation means tests can inject mock data directly into the dicts without touching Postgres, enabling full unit testing of collision detection, recollection rendering, and queue routing.
---
### Canonical Recollection Query
The recollection engine executes this query for each salient concept found in an intercepted prompt. No chain traversal — depth is always 1. The result is a flat enumeration of all edges where the concept is the subject, across all dimensions.
```sql
SELECT
u.id,
u.parent_id,
u.dim_id,
u.is_isa,
p.token AS parent_token,
d.token AS dim_token
FROM urd u
INNER JOIN soas p ON p.id = u.parent_id
INNER JOIN soas d ON d.id = u.dim_id
WHERE u.id = $1
AND u.confidence >= $2
AND u.last_confirmed >= $3
ORDER BY u.id, u.dim_id DESC;
```
`$1` = SOAS id of the concept, `$2` = confidence floor (from config), `$3` = recency cutoff (from config).
**Injection position:** the recollection block is prepended to the content of the existing system message if one is present. If no system message exists in the messages array, a new `{"role": "system"}` message containing only the recollection block is inserted at position 0. The system message is the highest-attention position in most instruction-tuned models — it is where grounding facts anchor most reliably.
**Rendering:** iterate rows; for each row emit `[dim_token] parent_token`. If the concept also has a pending entry in the resolution queue, append `?` to that dimension token. Group by concept when multiple concepts are queried in a single block.
**Zero-hit rendering:** if a concept is above the read threshold but has no URD entries, it is a salient domain-specific term the world model has not yet encountered. Instead of silently omitting it, the recollection block emits an explicit prompt to the agent:
```
? gnommoweb: no recollection. If this is a typo, ignore.
If you know what it is, store it before proceeding:
gutask iknowthat 'gnommoweb -isa <parent> in context of <dimension>'
gutask iknowthat 'gnommoweb -ispart <system> in context of <dimension>'
```
This turns every unknown salient concept into an active instruction. The agent either confirms it is a typo, asks for clarification from a human or peer agent, or fills the gap itself via `gutask iknowthat`. The world model grows organically through use.
**Full example recollection block:**
```
<recollection>
gnommoweb: [glitch_university] repo [geography] ramanujan [type?] service
dobby: [agent_pool] worker [tech] python
? ramanujan: no recollection. If you know what it is, store it before proceeding:
gutask iknowthat 'ramanujan -isa <parent> in context of <dimension>'
gutask iknowthat 'ramanujan -ispart <system> in context of <dimension>'
</recollection>
```
`[type?]` signals a pending conflict on that dimension — the world model is not wrong, resolution is in progress.
### gutask iknowthat — Manual Write Path
`gutask iknowthat` is the highest-confidence write path into URD. It bypasses the saliency threshold and the cloud LLM entirely.
**Location:** `/Users/jenstandstad/Projects/gutasktool` (sibling of Agent0 repo). The command POSTs to Festinger's `/iknowthat` HTTP endpoint — gutasktool does not connect to Postgres directly.
**Syntax:**
```sh
gutask iknowthat 'gnommoweb -isa repo in context of glitch_university'
gutask iknowthat 'gnommoweb -ispart glitch_university in context of membership'
```
- `-isa` sets `is_isa=true`; `-ispart` sets `is_isa=false`
- `in context of <dimension>` specifies the dimension token; defaults to `type` for `-isa`, `membership` for `-ispart`
- All tokens run through the standard tokeniser (compound token rule applies)
- Inserts into SOAS if any token is new
- Inserts into URD with `confidence=1.0`, `source=gutask`
- On collision: enters resolution queue with priority flag; `gutask`-source conflicts are reviewed first at `/conflicts`
**Festinger `/iknowthat` endpoint:** accepts `POST` with JSON body `{fact: string}`. Parses, tokenises, writes to SOAS/URD, returns the inserted or conflicted result. This decouples gutasktool from the Festinger schema.
This command is the agent's direct interface to the world model. When the recollection block surfaces a zero-hit concept, `gutask iknowthat` is the prescribed response.
---
## The IN Relation and ISA/ISPART
### Why a single operator
ISA and ISPART are both instances of a more general semantic containment relation. The dimension carries the semantic weight:
- `gnommoweb IN repo` (dimension: `type`) → gnommoweb ISA repo
- `gnommoweb IN Glitch-University` (dimension: `membership`) → gnommoweb ISPART Glitch-University
- `State IN Country` (dimension: `type`) → State ISA a country-level granularity
The `type` dimension IS the ISA relation. Every other dimension IS an ISPART relation scoped to that domain. One table, one index, one operator.
### Why this resolves the bleed
The classic bleed case: Michigan ISA State, Michigan ISPART USA, State ISPART Country.
With the single IN operator and dimensions:
```
Michigan IN State (dimension: type) is_isa: true
Michigan IN USA (dimension: geography) is_isa: false
State IN Country (dimension: type) is_isa: true
USA IN Country (dimension: type) is_isa: true
```
Two coherent chains, no collision, no ambiguity. "State ISPART Country" (class-level generalisation) becomes "State IN Country in the type dimension" — a perfectly valid ISA statement: State is a kind of country-level subdivision.
---
## Collision Semantics — The `is_isa` Flag
The unique index on `(concept_id, dimension_id)` fires when a concept already has a parent in a given dimension. The `is_isa` flag on both the existing and incoming rows determines what the collision means:
| Existing | Incoming | Interpretation | Action |
|----------|----------|---------------|--------|
| ISA | ISA | Dimension too coarse — both facts simultaneously true about the concept's nature | Trigger **dimension decomposition** |
| ISPART | ISPART | Factual contradiction — thing can only be in one place per dimension | Trigger **arbitration** (which is correct?) |
| ISA | ISPART | Dimension misclassification — these should have been in different dimensions | Flag as misclassification, suggest correct dimension |
---
## Dimension Decomposition — Dimensions as Evolving Vocabulary
When an ISA+ISA collision occurs, the dimension is too coarse to hold both simultaneously-true facts. Example:
- Existing: `gnommoweb IN container` (dimension: `type`, is_isa: true)
- Incoming: `gnommoweb IN repo` (dimension: `type`, is_isa: true)
Both are true. The `type` dimension cannot hold them both. The conflict resolver sends this prompt to the cloud LLM:
> "gnommoweb is already `container` in dimension `type`. New fact also places gnommoweb as `repo` in `type`. If both are simultaneously true, propose two more specific dimension names to replace `type` — one where gnommoweb as `container` remains valid, one where gnommoweb as `repo` is valid. Return JSON: `{"existing_dimension": "...", "new_dimension": "..."}`. Choose from this taxonomy where possible: [...]. Create new names only if nothing fits."
The LLM might return: `{"existing_dimension": "deployment-type", "new_dimension": "artifact-type"}`.
The system then:
1. Inserts `deployment-type` and `artifact-type` into SOAS (if not present)
2. Creates root nodes for each new dimension
3. Inserts the new fact under `artifact-type`
4. Leaves the existing `type` facts untouched — no migration
Dimensions are SOAS concepts. New dimensions emerge from the same token vocabulary. The graph grows its own taxonomy under pressure from real contradictions, starting coarse and decomposing on demand.
---
## The Two Memory Operations
### Tokenisation Rules
All text — prompts, system messages, agent outputs — passes through the same tokeniser before any saliency or relationship work is done.
**Token extraction:**
1. Split on whitespace and punctuation boundaries
2. **Compound token rule**: scan for runs of consecutive tokens where each begins with a capital letter. Merge the run into a single token, joined with underscores, then lowercase. This canonicalises proper nouns and multi-word concepts into single SOAS entries.
- `Glitch University``glitch_university`
- `Agent Zero``agent_zero`
- `New York City``new_york_city`
- A lowercase or short token breaks the run: `the Glitch University``the` breaks the run, `Glitch University``glitch_university`
3. Lowercase all tokens
4. Keep tokens with **5 or more characters** (strictly >4); discard shorter tokens unless they are part of a matched relationship cue pattern (see below)
5. Strip leading/trailing punctuation from each token
**Example:**
`"gnommoweb is a repo of Glitch University"`
→ tokens: `gnommoweb`, `repo`, `glitch_university`
→ relationship extracted: `gnommoweb IN repo IN dim:glitch_university` (is_isa=true, via "is a … of" pattern)
---
### Relationship Cue Patterns
Certain keyword patterns in intercepted text are direct cues to the memory layer that a semantic relationship is being expressed. The middleman scans every prompt for these patterns. When matched, the relationship is extracted and queued for insertion into the IN table — bypassing the saliency threshold, since the relationship has been made explicit.
Agents and humans interacting with Agent0 should be aware that using these patterns causes the middleman to build or update the world model.
**ISA patterns** (`is_isa = true`) — the subject is a type or instance of the object:
| Pattern | Example |
|---------|---------|
| `{X} is a {Y}` | gnommoweb is a repo |
| `{X} is an {Y}` | gnommoweb is an API |
| `{X} ISA {Y}` | gnommoweb ISA repo |
| `{X} is a kind of {Y}` | State is a kind of region |
| `{X} is a type of {Y}` | gnommoweb is a type of service |
| `{X} is an instance of {Y}` | dobby is an instance of agent |
| `{X} kind of {Y}` | gnommoweb kind of repo |
| `{X} type of {Y}` | gnommoweb type of service |
| `{X} instance of {Y}` | dobby instance of agent |
**ISPART patterns** (`is_isa = false`) — the subject is a member, part, or component of the object:
| Pattern | Example |
|---------|---------|
| `{X} is part of {Y}` | gnommoweb is part of Glitch University |
| `{X} ISPART {Y}` | gnommoweb ISPART glitch_university |
| `{X} part of {Y}` | gnommoweb part of Agent0 |
| `{X} belongs to {Y}` | gnommoweb belongs to Glitch University |
| `{X} is owned by {Y}` | gnommoweb is owned by jenstandstad |
| `{X} owned by {Y}` | gnommoweb owned by jenstandstad |
| `{X} member of {Y}` | dobby member of agent_pool |
| `{X} is a member of {Y}` | dobby is a member of agent_pool |
| `{X} runs on {Y}` | gnommoweb runs on Docker |
| `{X} hosted by {Y}` | gnommoweb hosted by ramanujan |
| `{X} deployed on {Y}` | gnommoweb deployed on Docker |
| `{X} contained in {Y}` | gnommoweb contained in agent0_stack |
**The `of {Z}` dimension modifier:**
When an ISA pattern is followed by `of {Z}`, the named entity `{Z}` becomes the dimension for the extracted edge. This allows natural language to directly specify the context in which a classification holds:
```
"gnommoweb is a repo of Glitch University"
→ gnommoweb IN repo IN dim:glitch_university (is_isa=true)
"Michigan is a state of USA"
→ michigan IN state IN dim:usa (is_isa=true)
```
Without the `of {Z}` modifier, the dimension defaults to `type` for ISA patterns and the most appropriate seed dimension for ISPART patterns (inferred by the cloud LLM during the write step, or defaulting to `membership`).
**Cue-triggered writes bypass the saliency threshold.** Explicit relationship cues are treated as high-confidence signals regardless of how many times a concept has previously appeared. The extracted triple goes directly into the write queue with `source: inferred` and a confidence score assigned by the pattern type (exact keyword cues score higher than positional inference).
---
### Writing — cloud-triggered, async
When a concept's saliency crosses the **write threshold**, Middleman queues it for background processing (cloud LLM calls must not block the prompt response path):
1. Call cloud LLM: "What is `{concept}`?" with the current dimension taxonomy as a closed list and a structured output prompt requesting `(concept, parent, dimension, is_isa)` triples
2. Parse response into IN table INSERT statements
3. Attempt inserts; route constraint violations to the conflict resolver
4. Update `novelty` score in SOAS for the concept
### Reading — spontaneous prompt enrichment
On every intercepted prompt:
1. Tokenise the full prompt string, extract tokens >4 chars, normalise
2. Look up each token in SOAS, update encounter counts and saliency
3. For tokens above the **read threshold**, query the IN table for all edges involving the concept
4. Traverse each chain upward (configurable max depth)
5. Format as a `<recollection>` block and prepend to the prompt before forwarding to Ollama
Example output:
```
<recollection>
gnommoweb: [type] repo → software-artifact
[membership] Glitch-University → Agent0-infrastructure
[tech] FastAPI → Python
glitch.university: [type] platform → web-service
[membership] Agent0 → Glitch-Hunter-project
</recollection>
```
Only edges above the confidence threshold and within the recency window are included. The agent does not search for these — they appear spontaneously.
**Read and write thresholds are separate and independently tunable.** Reading (DB lookup) is cheap; writing (cloud LLM call) is expensive. The write threshold should be meaningfully higher than the read threshold.
---
## Conflict Resolution — The Nightly Therapy Model
The IN table is rigorous and autistic: it cannot hold contradictions. Any collision is immediately routed to the **resolution queue** — a separate table — where it waits for the nightly resolution job to process it. During this period the world model stands unchanged; the old fact continues to be served in recollections, marked with a `?` to signal pending dissonance.
### On collision (immediate, synchronous)
1. Classify the collision type by reading `is_isa` on both rows: ISA+ISA, ISPART+ISPART, or misclassification
2. Insert the rejected fact into the **resolution queue** with full context: existing edge, incoming edge, dimension, collision type, timestamp
3. Return normally — the proxy response is not blocked
### During the day (recollection engine)
Concepts with entries in the resolution queue are rendered with a `?` marker:
```
<recollection>
gnommoweb: [type] container
[type?] repo — pending resolution
</recollection>
```
The agent sees that a fact is contested. The world model is not wrong — it is incomplete. The `?` marker disappears after the nightly job resolves or dismisses the conflict.
### Nightly resolution job
Runs as a background thread inside the Festinger proxy process on the schedule set in the `config` table (`resolution_schedule`). Can also be triggered manually via `POST /resolve/run` — a corresponding button is exposed in the Festinger admin UI. Uses the model configured in `resolve_model_id`.
For each item in the queue, calls the configured LLM with both facts and the collision type, receives a structured decision, and applies it:
**ISA+ISA collision (dimension too coarse):**
- LLM outcome A — **decompose**: suggest two new dimension names. System creates new SOAS entries and root nodes, inserts both facts into their respective new dimensions, marks queue item resolved.
- LLM outcome B — **dismiss**: the incoming fact is noise or wrong. Queue item marked dismissed. World model stands.
**ISPART+ISPART collision (factual contradiction):**
- LLM outcome A — **update**: the incoming fact is more current. System removes old IN edge, inserts new one, marks queue item resolved.
- LLM outcome B — **dismiss**: the existing fact is still correct. Queue item marked dismissed.
**Misclassification (ISA+ISPART in same dimension):**
- LLM suggests the correct dimension for the incoming fact. System inserts it in the corrected dimension (no collision), marks queue item resolved.
### Resolution queue schema
| Column | Type | Notes |
|--------|------|-------|
| id | INT PK | auto-increment |
| concept_id | INT FK | references SOAS |
| existing_parent_id | INT FK | the parent currently in the IN table |
| incoming_parent_id | INT FK | the rejected parent |
| dimension_id | INT FK | the dimension where the collision occurred |
| collision_type | ENUM | `isa_isa`, `ispart_ispart`, `misclassification` |
| status | ENUM | `pending`, `resolved`, `dismissed` |
| resolution | TEXT | JSON record of what the nightly job decided and did |
| created_at | TIMESTAMP | when the collision occurred |
| resolved_at | TIMESTAMP | when the nightly job processed it |
### Properties of this model
- **IN table is always consistent** — agents never receive contradictory recollections from confirmed facts
- **Resolution is deliberate, not reactive** — the nightly job processes dissonance calmly, with full context, not under prompt-response time pressure
- **Dismissal is a first-class outcome** — not every collision is a real problem; the LLM can decide the world model is correct and the incoming fact was noise
- **The `?` marker is the fuzziness adjunct** — it surfaces uncertainty to agents without compromising the graph's integrity
- **`/conflicts` endpoint** — exposes the full queue (pending and recently resolved) for human inspection and override
---
## Graph Properties
- **Acyclic**: the unique index on `(concept_id, dimension_id)` enforces single-parent per concept per dimension, making each dimension a forest of trees — structurally acyclic without any runtime check
- **Shared**: one graph for all agents — recollections represent shared facts about the project, not per-agent beliefs. Agents already have per-agent memory in Agent Zero's own FAISS layer
- **Contradiction-resistant**: the index makes it structurally impossible to store conflicting facts in the same dimension. Contradictions surface as insert failures, not silent overwrites
- **Self-organising**: dimension taxonomy starts with a small seed list and decomposes on demand. No human needs to pre-define the full ontology
---
## Saliency Decay
Two decay mechanisms, operating independently:
- **SOAS recency** (`last_seen` timestamp): concepts not seen for a long time are deprioritised for recollection injection but not deleted. If gnommoweb reappears in a prompt, `last_seen` updates and its recollections resurface immediately
- **IN edge recency** (`last_confirmed` timestamp): edges not corroborated by recent prompts are given lower weight in recollection injection, making the recollection block favour currently-relevant facts
Decay does not delete knowledge — it adjusts injection priority. The graph remains intact.
---
## Dimension Taxonomy — Seed List
A small, orthogonal set of initial dimensions. Each dimension answers a specific question about a concept. New dimensions emerge through decomposition; this list is the starting skeleton.
| Dimension | Question | is_isa |
|-----------|----------|--------|
| `type` | What kind of thing is this? | true |
| `membership` | What system or project does this belong to? | false |
| `runs-on` | What infrastructure hosts or executes this? | false |
| `tech` | What technology stack is it built with? | false |
| `owned-by` | Who is responsible for this? | false |
| `geography` | Where is this spatially or organisationally located? | false |
Root nodes for each dimension are seeded at bootstrap. The `type` dimension is expected to be the first to decompose as domain-specific concepts accumulate.
---
## Components
| Component | Description | State |
|-----------|-------------|-------|
| Proxy core | FastAPI Ollama-compatible HTTP proxy | ✅ built |
| Loop detector | Session-scoped repeat detection + mitigations | ✅ built |
| Config system | Hot-reloading YAML config | ✅ built |
| SOAS store | Concept vocabulary + saliency DB table | ✅ built |
| IN table store | Acyclic concept graph with correct indexes | ✅ built |
| Dictionary bootstrap | Pre-seed SOAS with common English at saliency 0 | ✅ built |
| Dimension bootstrap | Seed root nodes for initial dimension taxonomy | ✅ built |
| Saliency engine | Tokenise prompt, score tokens, update SOAS counts | ✅ built |
| Recollection engine | Query IN table, traverse chains, format + inject block | ✅ built |
| Memory writer | Write-threshold trigger → async cloud LLM → NL→IN parse → insert | ✅ built |
| Conflict resolver | On collision: classify type, insert into resolution queue immediately | ✅ built |
| Resolution queue | Pending/resolved/dismissed conflicts with full context | ✅ built |
| Nightly resolution job | Drain queue via cloud LLM; apply decompose/update/dismiss decisions | ✅ built |
| `/conflicts` endpoint | Expose queue (pending + recent) for human inspection and override | ✅ built |
| Persistence | Postgres from day one; English dictionary pre-loaded into SOAS at init | ✅ built |
---
## Task Breakdown
### Phase 1 — Foundation (complete)
- [x] **T01** Proxy core: FastAPI Ollama-compatible server forwarding `/api/chat` and `/api/generate`
- [x] **T02** Loop detector: session-scoped exact-match repetition detection
- [x] **T03** Mitigations: temperature boost, forbidden action injection, history truncation, circuit breaker
- [x] **T04** Hot-reload YAML config
- [x] **T05** Docker container + docker-compose service entry
### Phase 2 — Persistence Layer
- [x] **T06** Postgres service: add `postgres` container to docker-compose; connection config in `config.yaml`
- [x] **T07** `models` table: `id` SERIAL PK, `provider` VARCHAR, `model_name` VARCHAR, `api_key` VARCHAR, `created_at` TIMESTAMPTZ
- [x] **T08** `config` table: `key` VARCHAR PK, `value` TEXT, `updated_at` TIMESTAMPTZ; seed with default values for all config keys
- [x] **T09** SOAS table: `id` SERIAL PK, `token` VARCHAR UNIQUE, `encounter_count` INT default 0, `last_seen` TIMESTAMPTZ, `saliency` FLOAT default 0, `novelty` FLOAT default 0. All tokens lowercase. Unique index on `token`.
- [x] **T10** URD table: `id` INT FK → soas, `parent_id` INT FK → soas, `dim_id` INT FK → soas, `is_isa` BOOLEAN, `confidence` FLOAT, `last_confirmed` TIMESTAMPTZ, `source` VARCHAR. PK `(id, parent_id, dim_id)`. Unique index `(id, dim_id)`.
- [x] **T11** Resolution queue table: `id` SERIAL PK, `concept_id` INT FK → soas, `existing_parent_id` INT FK → soas, `incoming_parent_id` INT FK → soas, `dim_id` INT FK → soas, `collision_type` VARCHAR, `status` VARCHAR default 'pending', `resolution` JSONB, `created_at` TIMESTAMPTZ, `resolved_at` TIMESTAMPTZ
- [x] **T12** English dictionary bootstrap: bulk-load word list into SOAS with `saliency=0`, `novelty=0`, `encounter_count=0` on container init. Skip existing tokens.
- [x] **T13** Dimension bootstrap: insert SOAS entries and self-referential URD root nodes (`id = parent_id = dim_id`) for the 6 seed dimensions
### Phase 3 — Saliency Engine and Prompt Parsing
- [x] **T14** Tokeniser: split on whitespace/punctuation; apply compound token rule (consecutive capitalised tokens → single underscore-joined lowercase token); no minimum length; strip punctuation; lowercase all
- [x] **T15** Relationship cue scanner: regex/pattern scan for ISA and ISPART cue patterns; extract `(subject, parent, dimension_modifier, is_isa)` triples; handle `of {Z}` dimension modifier
- [x] **T16** SOAS lookup + update: increment `encounter_count`, update `last_seen`, recalculate `saliency` (log scale) for all extracted tokens; read thresholds from `config` table
- [x] **T17** Threshold evaluation: read `saliency_read_threshold` and `saliency_write_threshold` from config; cue-extracted triples bypass write-threshold entirely
### Phase 4 — Recollection Engine (Read Path)
- [x] **T16** URD query: for each above-read-threshold token, execute the canonical recollection query; filter by `confidence` floor and `last_confirmed` recency window
- [x] **T17** Recollection formatter — hit path: enumerate query rows; group by concept; render each edge as `[dim_token] parent_token`; append `?` for edges with a pending resolution queue entry
- [x] **T18** Recollection formatter — zero-hit path: for salient concepts with no URD rows, emit the `? concept: no recollection` prompt block including the `gutask iknowthat` usage hint
- [x] **T19** Prompt injection: prepend recollection block before forwarding to Ollama
- [x] **T20** Recollection config: max concepts per block, confidence floor, recency window, injection position
### Phase 5 — Memory Writer (Write Path)
- [x] **T21** `POST /iknowthat` endpoint: accept `{fact: string}`, parse `-isa`/`-ispart` flags and `in context of` clause, run tokeniser, upsert SOAS, insert into URD with `confidence=1.0, source=gutask`; route collisions to resolution queue with priority flag
- [x] **T22** `gutask iknowthat` command (in `/Users/jenstandstad/Projects/gutasktool`): parse fact string, POST to Festinger `/iknowthat`, surface result to agent
- [x] **T23** Write queue: async background queue for concepts crossing the write threshold; cue-extracted triples enter directly regardless of threshold
- [x] **T24** LLM client: support `claude` and `openai` providers; load provider/model/key from `models` table via `write_model_id` config key; structured prompt requesting `(concept, parent, dimension, is_isa, confidence)` triples as JSON
- [x] **T25** NL→IN parser: validate triples against SOAS and known dimensions; create new SOAS entries for unknown tokens; apply compound token rule
- [x] **T26** URD insert pipeline: check `urd_by_concept_dim` in-memory first; on miss attempt Postgres insert; on hit or UniqueViolation route to conflict resolver; set `source` field per write path
### Phase 6 — Conflict Resolution
- [x] **T25** Collision handler: on unique constraint violation, classify type via `is_isa` flags, insert immediately into resolution queue
- [x] **T26** Recollection engine update: check resolution queue for pending items per concept; render pending edges with `[dim?]` marker
- [x] **T27** Nightly resolution job: background thread, schedule from `config` table (`resolution_schedule`); for each `pending` queue item, call LLM configured in `resolve_model_id` with both facts and collision type, receive JSON decision (`decompose` / `update` / `dismiss`)
- [x] **T28** Resolution applicator — decompose: create new dimension SOAS entries and root nodes; insert both facts in respective new dimensions; mark queue item resolved
- [x] **T29** Resolution applicator — update: remove old IN edge, insert new fact, mark queue item resolved
- [x] **T30** Resolution applicator — dismiss: mark queue item dismissed; world model unchanged; the `[dim?]` marker disappears from recollections
- [x] **T31** `/conflicts` endpoint: list pending and recently resolved/dismissed items with full context; support human override (force-dismiss, force-resolve)
- [x] **T32** `POST /resolve/run` endpoint: manually trigger the nightly resolution job outside of its schedule
- [x] **T33** Admin UI: minimal HTML page served by Festinger at `/admin`; shows pending conflicts count, last resolution run timestamp, and a "Run resolution now" button wired to `POST /resolve/run`
### Phase 7 — Hardening
- [ ] **T32** Latency guard: tokenisation + saliency lookup + recollection query must not add >50ms to prompt round-trip; use connection pooling
- [ ] **T33** Write path fully async: all cloud LLM calls, IN inserts, and queue operations run in background workers; proxy response never waits
- [ ] **T34** Integration test: plain prompt → compound token extraction → saliency update → recollection injection → Ollama round trip
- [ ] **T35** Integration test: cue pattern in prompt ("gnommoweb is a repo of Glitch University") → extracted triple bypasses threshold → IN insert → recollection on next prompt
- [ ] **T36** Integration test: write threshold → cloud LLM write → IN insert → recollection on next prompt
- [ ] **T37** Integration test: ISA+ISA collision → immediate queue insert → `[type?]` marker in recollection → nightly job → decompose → clean recollection across two dimensions
- [ ] **T38** Integration test: ISPART+ISPART collision → queue insert → nightly job → dismiss → world model unchanged, marker gone
---
## Resolved Design Decisions
| Question | Decision |
|----------|----------|
| Single relation or ISA+ISPART tables? | Single IN operator; `is_isa` boolean flag outside the index annotates edge type |
| ISA vs ISPART bleed | Resolved by dimension: `type` dimension = ISA; all other dimensions = ISPART |
| Shared vs per-agent graph | Shared — recollections are project-wide facts; per-agent memory remains in Agent Zero's FAISS layer |
| Saliency decay | Dual mechanism: SOAS `last_seen` for concept recency; IN `last_confirmed` for edge recency. Decay adjusts injection priority, never deletes knowledge |
| Recollection depth | Depth = 1, no chain traversal. Single flat query against URD for direct edges of each salient concept. Property-loop problem is dissolved by design — self-referential chains cannot arise without traversal. |
| Write path blocking | Fully async — cloud LLM calls queued in background, never block proxy response |
| Dimension taxonomy | Seed list of 6 orthogonal dimensions; decomposes on demand via nightly resolution job when ISA+ISA collision is queued |
| Collision semantics | ISA+ISA → dimension too coarse → decompose. ISPART+ISPART → factual contradiction → arbitrate. ISA+ISPART → misclassification → flag |
| Contradiction resistance | Structural — unique index on `(concept_id, dimension_id)` makes conflicting facts physically uninsertable in the same dimension |
| New dimensions | Emerge as SOAS tokens; no schema change needed; root nodes created at decomposition time |
| Conflict resolution timing | Immediate queue insert on collision; nightly job drains the queue via cloud LLM; outcomes are decompose / update / dismiss |
| Database | Postgres from day one — no SQLite POC. English dictionary bulk-loaded into SOAS at container init. |
| Tokenisation | Tokens ≥5 chars, lowercased. Consecutive capitalised tokens merged into single underscore-joined token (`Glitch University``glitch_university`). |
| Relationship cue parsing | ISA and ISPART keyword patterns in intercepted text trigger direct triple extraction, bypassing saliency threshold. `of {Z}` modifier sets the dimension. |
| Cue-triggered writes | Explicit cue patterns are high-confidence signals; extracted triples go straight to the write queue with `source: inferred`, no threshold gate. |
| Zero-hit recollection | Salient concepts with no URD entries emit a `? concept: no recollection` prompt block instructing the agent to clarify or store the fact via `gutask iknowthat`. |
| Manual write path | `gutask iknowthat 'X -isa/-ispart Y in context of Z'` inserts directly into URD with `confidence=1.0, source=manual`, bypassing saliency threshold and cloud LLM. |
| In-memory layer | SOAS and URD cached in Python dicts at startup. Reads are zero-network. IDs always originate from Postgres. Collision detection uses `dict[(concept_id, dim_id)]` mirroring the Postgres unique index. Postgres is the safety net for race conditions. |
| Token length | No minimum — all tokens indexed. Frequency and novelty determine whether they surface. Optimize later if needed. |
| Nightly job execution | Background thread inside proxy process. Triggered by cron schedule (config table) or manually via `POST /resolve/run` + admin UI button. |
| Recollection injection | Prepended to existing system message content. If no system message exists, a new one is inserted at position 0. System message position provides the strongest grounding anchor for instruction-tuned models. |
| LLM configuration | `models` table (provider, model_name, api_key). `config` table keys `write_model_id` and `resolve_model_id` select which model each purpose uses. Supports `claude` and `openai` providers. |
| Source values | `cloud_llm` (saliency write path), `inferred` (cue pattern extraction), `festinger` (nightly resolution job), `gutask` (gutask iknowthat command). |
| gutask iknowthat interface | gutasktool at `/Users/jenstandstad/Projects/gutasktool`. Command POSTs to Festinger's `/iknowthat` endpoint — no direct Postgres access from gutasktool. |
---
## Test Cases
The in-memory cache layer enables full unit testing without a live database. Tests pre-populate `soas_by_token`, `soas_by_id`, `urd_by_concept`, `urd_by_concept_dim`, and `pending_conflicts` directly, then exercise the logic under test and assert on the resulting state.
---
### Test A — Prompt includes a concept not in the cache
**Scenario:** An agent sends a prompt referencing `gnommoweb`. The concept exists nowhere in SOAS or URD.
**Setup:**
```python
soas_by_token = {} # empty — concept is completely unknown
urd_by_concept = {}
urd_by_concept_dim = {}
pending_conflicts = set()
```
**Input prompt:** `"Please update gnommoweb to use FastAPI instead"`
**Expected behaviour:**
1. Tokeniser extracts: `gnommoweb` (7 chars ✓), `fastapi` (7 chars ✓), `please` (6 chars but common English → saliency 0), `update` (6 chars, common → saliency 0), `instead` (7 chars, common → saliency 0)
2. `gnommoweb` and `fastapi` not in `soas_by_token` → both are new tokens. In a test with Postgres mocked, the mock returns `id=101` for `gnommoweb` and `id=102` for `fastapi`. Both added to SOAS dicts.
3. Saliency for both: `log(1)` — first encounter, below read threshold
4. URD lookup: skipped (below threshold) — no recollection block emitted for these concepts
**Edge case variant — above threshold:** pre-seed `soas_by_token["gnommoweb"]` with `encounter_count=50, saliency=0.9` (above read threshold) but keep `urd_by_concept` empty.
**Expected behaviour (variant):**
1. Saliency lookup: above read threshold ✓
2. URD lookup: `urd_by_concept.get(101, [])` → empty list
3. Zero-hit path triggered
4. Recollection block contains:
```
? gnommoweb: no recollection. If not a typo, store it before proceeding:
gutask iknowthat 'gnommoweb -isa <parent> in context of <dimension>'
gutask iknowthat 'gnommoweb -ispart <system> in context of <dimension>'
```
**Assertions:**
- `pending_conflicts` unchanged (no collision occurred)
- Resolution queue empty
- Recollection block contains `? gnommoweb`
- Prompt forwarded to Ollama with recollection block prepended
---
### Test B — Prompt includes "A ISA B" conflicting with existing world model
**Scenario:** The world model already holds `gnommoweb ISA repo` in the `type` dimension. A new prompt contains the explicit cue `"gnommoweb is a container"`, creating an ISA+ISA collision.
**Setup:**
```python
# SOAS
soas_by_token = {
"gnommoweb": SoasRow(id=101, saliency=1.2, novelty=1.0),
"repo": SoasRow(id=201, saliency=0.8, novelty=0.5),
"container": SoasRow(id=202, saliency=0.6, novelty=0.4),
"type": SoasRow(id=1, saliency=0.0, novelty=0.0), # seed dimension
}
soas_by_id = {101: "gnommoweb", 201: "repo", 202: "container", 1: "type"}
# URD — gnommoweb ISA repo IN type (existing confirmed fact)
existing_edge = UrdEdge(concept_id=101, parent_id=201, dim_id=1,
is_isa=True, confidence=0.9, source="cloud_llm")
urd_by_concept = {101: [existing_edge]}
urd_by_concept_dim = {(101, 1): existing_edge}
pending_conflicts = set()
```
**Input prompt:** `"gnommoweb is a container deployed on Docker"`
**Expected behaviour:**
1. Cue scanner matches `"is a"` pattern: extracts triple `(gnommoweb, container, type, is_isa=True)`
2. Collision check: `(101, 1)` found in `urd_by_concept_dim` → collision
3. Existing edge `is_isa=True`, incoming `is_isa=True` → ISA+ISA collision type
4. Resolution queue insert (mocked Postgres): `{concept_id=101, existing_parent_id=201, incoming_parent_id=202, dim_id=1, collision_type="isa_isa", status="pending"}`
5. `pending_conflicts.add(101)`
6. No URD modification — world model unchanged
**Recollection block for this prompt:**
```
<recollection>
gnommoweb: [type?] repo — conflict pending
</recollection>
```
**Assertions:**
- `urd_by_concept_dim[(101, 1)]` still points to the original `repo` edge (unchanged)
- `pending_conflicts == {101}`
- Resolution queue has exactly one entry with `collision_type="isa_isa"` and `status="pending"`
- No Postgres URD insert attempted
- Recollection renders `[type?]` marker for `gnommoweb`
---
### Test C — Nightly job: queue processing and dimension decomposition
**Scenario:** The resolution queue contains the ISA+ISA collision from Test B. The nightly job runs, calls the cloud LLM (mocked), receives a decomposition decision, and updates the world model.
**Setup:** state as at end of Test B, plus:
```python
resolution_queue = [
QueueEntry(id=1, concept_id=101, existing_parent_id=201, incoming_parent_id=202,
dim_id=1, collision_type="isa_isa", status="pending")
]
```
**Mocked cloud LLM response:**
```json
{
"decision": "decompose",
"existing_dimension": "artifact-type",
"new_dimension": "deployment-type",
"reasoning": "repo describes what gnommoweb is as a software artifact; container describes how it is deployed"
}
```
**Expected behaviour:**
1. Nightly job fetches pending queue entries from Postgres
2. For entry id=1, calls cloud LLM → receives decompose decision
3. **New dimensions:** insert `artifact-type` into Postgres SOAS → returns `id=401`; insert `deployment-type` → returns `id=402`. Add both to SOAS dicts.
4. **Root nodes:** insert `(401,401,401)` and `(402,402,402)` into Postgres URD (self-referential dimension roots)
5. **Migrate existing edge:** delete `(101, 201, 1)` from URD, insert `(101, 201, 401)` — gnommoweb ISA repo IN artifact-type
6. **Insert new edge:** insert `(101, 202, 402)` — gnommoweb ISA container IN deployment-type
7. **Update in-memory cache:**
- `soas_by_token["artifact-type"] = SoasRow(id=401, ...)`
- `soas_by_token["deployment-type"] = SoasRow(id=402, ...)`
- Remove `urd_by_concept_dim[(101, 1)]`, add `[(101, 401)]` and `[(101, 402)]`
- Update `urd_by_concept[101]` to two new edges
- `pending_conflicts.discard(101)`
8. Mark queue entry resolved with resolution JSON
9. Signal proxy `/reload` (or nightly job updates cache directly if in-process)
**Final recollection block for gnommoweb:**
```
<recollection>
gnommoweb: [artifact-type] repo [deployment-type] container
</recollection>
```
**Assertions:**
- `urd_by_concept_dim` contains `(101, 401)` and `(101, 402)`, not `(101, 1)`
- `urd_by_concept[101]` has exactly two edges
- `pending_conflicts` does not contain `101`
- `soas_by_token` contains `artifact-type` and `deployment-type` with ids from Postgres
- Resolution queue entry has `status="resolved"` and non-null `resolution` JSON
- No `[type?]` marker in subsequent recollection for gnommoweb
+57
View File
@@ -0,0 +1,57 @@
# Festinger Proxy Configuration
# Where the real Ollama is running (for /api/chat, /api/generate, /api/*)
upstream_ollama: "http://host.docker.internal:11434"
# Where the real Anthropic API is running (for /v1/messages)
# Override via UPSTREAM_ANTHROPIC env var if needed
upstream_anthropic: "https://api.anthropic.com"
# Where the real OpenAI-compatible API is running (for /v1/chat/completions)
# Override via UPSTREAM_OPENAI env var if needed
upstream_openai: "https://api.openai.com"
# Port this proxy listens on inside the container (exposed as 11434 on the docker network)
proxy_port: 11434
# Postgres connection string (overridable via POSTGRES_DSN env var)
postgres_dsn: "postgresql://festinger:festinger@postgres:5432/festinger"
detection:
# How many recent completions to keep per session
window_size: 5
# Minimum response length to bother checking (ignore trivial one-liners)
min_length: 20
mitigations:
# Applied in order. circuit_breaker short-circuits all others.
- strategy: temperature_boost
enabled: true
trigger_count: 2 # kick in after this many identical responses in a row
boost_amount: 0.35 # added to whatever temperature the request specified
max_temperature: 1.4 # ceiling
- strategy: forbidden_action
enabled: true
trigger_count: 2
# {count} is replaced with the consecutive repeat count
injection_message: >
STOP. You have produced the exact same response {count} times in a row.
You are FORBIDDEN from generating that response again.
You MUST try a completely different approach: either use a different tool,
break the task into smaller steps, or write a note explaining why you are
stuck and then stop.
- strategy: history_truncation
enabled: true
trigger_count: 3 # more aggressive — only after 3 repeats
truncate_turns: 6 # drop this many turns from the tail of the message history
- strategy: circuit_breaker
enabled: true
trigger_count: 5 # hard stop after 5 identical responses
response_message: >
[LOOP DETECTOR] This agent has produced the same response 5 times in a row.
The task has been halted to prevent an infinite loop.
A human operator should review the task state and restart with fresh context.
+83
View File
@@ -0,0 +1,83 @@
-- Festinger schema
-- Run once at container init via db.py:init_schema()
-- ---------------------------------------------------------------------------
-- models — LLM provider configuration
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS models (
id SERIAL PRIMARY KEY,
provider VARCHAR(32) NOT NULL, -- 'claude' or 'openai'
model_name VARCHAR(128) NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- config — runtime key-value configuration
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS config (
key VARCHAR(64) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO config (key, value) VALUES
('saliency_read_threshold', '0.5'),
('saliency_write_threshold', '1.2'),
('recollection_confidence_floor','0.6'),
('recollection_recency_days', '90'),
('resolution_schedule', '0 2 * * *'),
('write_model_id', ''),
('resolve_model_id', '')
ON CONFLICT (key) DO NOTHING;
-- ---------------------------------------------------------------------------
-- soas — concept vocabulary and saliency
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS soas (
id SERIAL PRIMARY KEY,
token VARCHAR(256) UNIQUE NOT NULL,
encounter_count INT NOT NULL DEFAULT 0,
last_seen TIMESTAMPTZ,
saliency FLOAT NOT NULL DEFAULT 0.0,
novelty FLOAT NOT NULL DEFAULT 0.0
);
CREATE INDEX IF NOT EXISTS soas_token_idx ON soas (token);
-- ---------------------------------------------------------------------------
-- urd — acyclic concept graph (the IN table)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS urd (
id INT NOT NULL REFERENCES soas(id),
parent_id INT NOT NULL REFERENCES soas(id),
dim_id INT NOT NULL REFERENCES soas(id),
is_isa BOOLEAN NOT NULL DEFAULT false,
confidence FLOAT NOT NULL DEFAULT 1.0,
last_confirmed TIMESTAMPTZ NOT NULL DEFAULT now(),
source VARCHAR(32) NOT NULL DEFAULT 'cloud_llm',
PRIMARY KEY (id, parent_id, dim_id)
);
-- One parent per concept per dimension — contradiction-resistance mechanism
CREATE UNIQUE INDEX IF NOT EXISTS urd_concept_dim_idx ON urd (id, dim_id);
-- ---------------------------------------------------------------------------
-- resolution_queue — pending conflicts waiting for nightly resolution
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS resolution_queue (
id SERIAL PRIMARY KEY,
concept_id INT NOT NULL REFERENCES soas(id),
existing_parent_id INT NOT NULL REFERENCES soas(id),
incoming_parent_id INT NOT NULL REFERENCES soas(id),
dim_id INT NOT NULL REFERENCES soas(id),
collision_type VARCHAR(32) NOT NULL, -- 'isa_isa', 'ispart_ispart', 'misclassification'
status VARCHAR(16) NOT NULL DEFAULT 'pending',
resolution JSONB,
priority BOOLEAN NOT NULL DEFAULT false, -- gutask conflicts reviewed first
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS rq_status_idx ON resolution_queue (status);
CREATE INDEX IF NOT EXISTS rq_concept_idx ON resolution_queue (concept_id);
+1
View File
@@ -0,0 +1 @@
# Festinger — Ollama-compatible inference middleware for Agent0
+72
View File
@@ -0,0 +1,72 @@
"""
In-memory cache layer.
All read operations on SOAS and URD hit these structures only — zero network
on the hot path. Writes are write-through: in-memory first, then Postgres.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class SoasRow:
id: int
token: str
encounter_count: int = 0
saliency: float = 0.0
novelty: float = 0.0
@dataclass
class UrdEdge:
concept_id: int
parent_id: int
dim_id: int
is_isa: bool
confidence: float
source: str
parent_token: str = ""
dim_token: str = ""
# ---------------------------------------------------------------------------
# Module-level cache dicts
# ---------------------------------------------------------------------------
# SOAS — primary lookup by token (mirrors UNIQUE index on token)
soas_by_token: dict[str, SoasRow] = {}
# SOAS — reverse lookup by id
soas_by_id: dict[int, str] = {}
# URD — recollection reads: concept_id → list of edges (tokens pre-joined)
urd_by_concept: dict[int, list[UrdEdge]] = {}
# URD — collision detection: (concept_id, dim_id) → edge
# Mirrors Postgres UNIQUE index on (id, dim_id) exactly
urd_by_concept_dim: dict[tuple[int, int], UrdEdge] = {}
# Concepts with pending items in resolution_queue
pending_conflicts: set[int] = set()
# Batched encounter count deltas — flushed to Postgres every 30 s
_encounter_deltas: dict[int, int] = {}
def record_encounter(soas_id: int) -> None:
"""Increment in-memory encounter count and stage a flush delta."""
_encounter_deltas[soas_id] = _encounter_deltas.get(soas_id, 0) + 1
if soas_id in {row.id for row in soas_by_token.values()}:
token = soas_by_id.get(soas_id)
if token and token in soas_by_token:
soas_by_token[token].encounter_count += 1
def drain_deltas() -> dict[int, int]:
"""Return and clear the accumulated encounter deltas for Postgres flush."""
global _encounter_deltas
batch = dict(_encounter_deltas)
_encounter_deltas = {}
return batch
+172
View File
@@ -0,0 +1,172 @@
"""
Relationship cue scanner.
Scans intercepted text for explicit ISA / ISPART natural language patterns.
When matched, extracts a (subject, parent, dimension, is_isa) triple that
bypasses the saliency write threshold and goes directly to the write queue.
The `of {Z}` modifier after an ISA pattern names the dimension explicitly.
Without it, ISA defaults to dimension 'type'; ISPART defaults to 'membership'.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
@dataclass
class CueTriple:
subject: str # canonical token (lowercase, compound rule applied)
parent: str # canonical token
dimension: str # canonical token, defaults to 'type' or 'membership'
is_isa: bool
confidence: float # pattern-type confidence
# ---------------------------------------------------------------------------
# Pattern definitions
# ---------------------------------------------------------------------------
# Each entry: (regex pattern, is_isa, base_confidence)
# Groups: (?P<subj>...) (?P<parent>...) optional (?P<dim>...)
_RAW_PATTERNS: list[tuple[str, bool, float]] = [
# ISA — explicit keyword forms (higher confidence)
(r"(?P<subj>\S+)\s+ISA\s+(?P<parent>\S+)", True, 0.95),
(r"(?P<subj>\S+)\s+is\s+an?\s+instance\s+of\s+(?P<parent>\S+)", True, 0.90),
(r"(?P<subj>\S+)\s+is\s+a\s+kind\s+of\s+(?P<parent>\S+)", True, 0.90),
(r"(?P<subj>\S+)\s+is\s+a\s+type\s+of\s+(?P<parent>\S+)", True, 0.90),
(r"(?P<subj>\S+)\s+kind\s+of\s+(?P<parent>\S+)", True, 0.80),
(r"(?P<subj>\S+)\s+type\s+of\s+(?P<parent>\S+)", True, 0.80),
(r"(?P<subj>\S+)\s+instance\s+of\s+(?P<parent>\S+)", True, 0.80),
# ISA — "is a / is an" (most common, must handle "of Z" modifier)
(r"(?P<subj>\S+)\s+is\s+an?\s+(?P<parent>\S+)", True, 0.85),
# ISPART — explicit keyword forms
(r"(?P<subj>\S+)\s+ISPART\s+(?P<parent>\S+)", False, 0.95),
(r"(?P<subj>\S+)\s+is\s+part\s+of\s+(?P<parent>\S+)", False, 0.90),
(r"(?P<subj>\S+)\s+is\s+a\s+member\s+of\s+(?P<parent>\S+)", False, 0.90),
(r"(?P<subj>\S+)\s+is\s+owned\s+by\s+(?P<parent>\S+)", False, 0.90),
(r"(?P<subj>\S+)\s+belongs\s+to\s+(?P<parent>\S+)", False, 0.85),
(r"(?P<subj>\S+)\s+part\s+of\s+(?P<parent>\S+)", False, 0.80),
(r"(?P<subj>\S+)\s+member\s+of\s+(?P<parent>\S+)", False, 0.80),
(r"(?P<subj>\S+)\s+owned\s+by\s+(?P<parent>\S+)", False, 0.80),
(r"(?P<subj>\S+)\s+runs\s+on\s+(?P<parent>\S+)", False, 0.85),
(r"(?P<subj>\S+)\s+hosted\s+by\s+(?P<parent>\S+)", False, 0.85),
(r"(?P<subj>\S+)\s+deployed\s+on\s+(?P<parent>\S+)", False, 0.85),
(r"(?P<subj>\S+)\s+contained\s+in\s+(?P<parent>\S+)", False, 0.85),
]
# Compiled patterns + metadata
_PATTERNS: list[tuple[re.Pattern, bool, float]] = [
(re.compile(p, re.IGNORECASE), is_isa, conf)
for p, is_isa, conf in _RAW_PATTERNS
]
# "of {Z}" dimension modifier following an ISA match (optional).
# Captures the first word only; _extend_compound() handles multi-word.
_OF_DIM_RE = re.compile(r"\s+of\s+(?P<dim>\S+)", re.IGNORECASE)
# Consecutive capital-starting words after a position
_CAPITAL_WORD_RE = re.compile(r"\s+([A-Z]\w*)")
# ---------------------------------------------------------------------------
# Token canonicalisation + compound extension
# ---------------------------------------------------------------------------
def _canonicalize(word: str) -> str:
"""Strip punctuation, apply lowercase. Single word — no compound rule."""
return re.sub(r"^[^\w]+|[^\w]+$", "", word).lower()
def _extend_compound(text: str, after: int, first_word: str) -> str:
"""
Starting from position `after` in `text`, consume any immediately-following
capital-starting words and merge them with `first_word` into a single
underscore-joined lowercase compound token.
Example:
text = "gnommoweb is part of Glitch University today"
after = position after "Glitch"
first_word = "Glitch"
→ returns "glitch_university"
"""
words = [_canonicalize(first_word)]
pos = after
while True:
m = _CAPITAL_WORD_RE.match(text, pos)
if not m:
break
words.append(_canonicalize(m.group(1)))
pos = m.end()
return "_".join(words)
# ---------------------------------------------------------------------------
# Main scanner
# ---------------------------------------------------------------------------
def scan_cues(text: str) -> list[CueTriple]:
"""
Scan *text* for relationship cue patterns. Returns all matched triples.
Deduplicates by (subject, parent, dimension, is_isa).
Multi-word proper-noun parents and dimensions are merged via _extend_compound().
"""
results: list[CueTriple] = []
seen: set[tuple] = set()
for pattern, is_isa, base_conf in _PATTERNS:
for m in pattern.finditer(text):
raw_subj = m.group("subj")
raw_parent = m.group("parent")
if not raw_subj or not raw_parent:
continue
subj = _canonicalize(raw_subj)
# Extend parent into compound if followed by more capital words
parent = _extend_compound(text, m.end("parent"), raw_parent)
if not subj or not parent:
continue
# Check for "of {Z}" dimension modifier immediately after the match
dimension: str
if is_isa:
suffix_start = m.end()
of_match = _OF_DIM_RE.match(text, suffix_start)
if of_match:
raw_dim = of_match.group("dim")
dimension = _extend_compound(text, of_match.end("dim"), raw_dim)
else:
dimension = "type"
else:
dimension = _infer_ispart_dimension(m.re.pattern)
key = (subj, parent, dimension, is_isa)
if key not in seen:
seen.add(key)
results.append(CueTriple(
subject=subj,
parent=parent,
dimension=dimension,
is_isa=is_isa,
confidence=base_conf,
))
return results
def _infer_ispart_dimension(pattern_str: str) -> str:
"""Guess a sensible default dimension from the ISPART pattern text."""
if "runs" in pattern_str or "deployed" in pattern_str:
return "runs-on"
if "hosted" in pattern_str:
return "runs-on"
if "owned" in pattern_str:
return "owned-by"
if "part" in pattern_str or "contained" in pattern_str:
return "membership"
if "member" in pattern_str or "belongs" in pattern_str:
return "membership"
return "membership"
+294
View File
@@ -0,0 +1,294 @@
"""
Database layer — asyncpg pool, schema init, cache warm-up, flush.
"""
from __future__ import annotations
import asyncio
import logging
import math
import os
from pathlib import Path
import asyncpg
from . import cache
from .cache import SoasRow, UrdEdge
log = logging.getLogger("festinger.db")
_pool: asyncpg.Pool | None = None
SCHEMA_PATH = Path(__file__).parent.parent / "db" / "schema.sql"
SEED_DIMENSIONS = [
("type", True),
("membership", False),
("runs-on", False),
("tech", False),
("owned-by", False),
("geography", False),
]
async def get_pool(dsn: str) -> asyncpg.Pool:
global _pool
if _pool is None:
_pool = await asyncpg.create_pool(dsn, min_size=2, max_size=10)
return _pool
async def close_pool() -> None:
global _pool
if _pool:
await _pool.close()
_pool = None
# ---------------------------------------------------------------------------
# Schema init
# ---------------------------------------------------------------------------
async def init_schema(pool: asyncpg.Pool) -> None:
sql = SCHEMA_PATH.read_text()
async with pool.acquire() as conn:
await conn.execute(sql)
log.info("schema applied")
# ---------------------------------------------------------------------------
# Bootstrap helpers
# ---------------------------------------------------------------------------
async def bootstrap_dimensions(pool: asyncpg.Pool) -> None:
"""Ensure the 6 seed dimensions exist in SOAS and have self-referential root nodes."""
async with pool.acquire() as conn:
for token, _is_isa in SEED_DIMENSIONS:
row = await conn.fetchrow(
"INSERT INTO soas (token, saliency, novelty) VALUES ($1, 0, 0) "
"ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token "
"RETURNING id, token, encounter_count, saliency, novelty",
token,
)
dim_id = row["id"]
# Self-referential root node: id = parent_id = dim_id
await conn.execute(
"INSERT INTO urd (id, parent_id, dim_id, is_isa, confidence, source) "
"VALUES ($1, $1, $1, $2, 1.0, 'festinger') "
"ON CONFLICT DO NOTHING",
dim_id, True,
)
log.info("seed dimensions bootstrapped")
async def bootstrap_english_dictionary(pool: asyncpg.Pool) -> None:
"""
Bulk-load /usr/share/dict/words into SOAS at saliency=0, novelty=0.
Skips tokens already present. Only loads tokens >= 5 chars.
"""
dict_file = Path("/usr/share/dict/words")
if not dict_file.exists():
log.warning("no system dictionary found at %s — skipping bootstrap", dict_file)
return
words = [
w.strip().lower()
for w in dict_file.read_text().splitlines()
if len(w.strip()) >= 5 and w.strip().isalpha()
]
# Deduplicate
words = list(set(words))
log.info("loading %d dictionary words into soas …", len(words))
async with pool.acquire() as conn:
# Use executemany with ON CONFLICT DO NOTHING for speed
await conn.executemany(
"INSERT INTO soas (token, saliency, novelty) VALUES ($1, 0, 0) "
"ON CONFLICT (token) DO NOTHING",
[(w,) for w in words],
)
log.info("dictionary bootstrap complete")
# ---------------------------------------------------------------------------
# Cache warm-up
# ---------------------------------------------------------------------------
async def warm_cache(pool: asyncpg.Pool) -> None:
"""Load all SOAS and URD rows into the in-memory cache."""
async with pool.acquire() as conn:
soas_rows = await conn.fetch(
"SELECT id, token, encounter_count, saliency, novelty FROM soas"
)
for r in soas_rows:
row = SoasRow(
id=r["id"],
token=r["token"],
encounter_count=r["encounter_count"],
saliency=r["saliency"],
novelty=r["novelty"],
)
cache.soas_by_token[r["token"]] = row
cache.soas_by_id[r["id"]] = r["token"]
urd_rows = await conn.fetch(
"""
SELECT u.id, u.parent_id, u.dim_id, u.is_isa, u.confidence, u.source,
p.token AS parent_token, d.token AS dim_token
FROM urd u
INNER JOIN soas p ON p.id = u.parent_id
INNER JOIN soas d ON d.id = u.dim_id
-- skip self-referential dimension root nodes
WHERE NOT (u.id = u.parent_id AND u.id = u.dim_id)
"""
)
for r in urd_rows:
edge = UrdEdge(
concept_id=r["id"],
parent_id=r["parent_id"],
dim_id=r["dim_id"],
is_isa=r["is_isa"],
confidence=r["confidence"],
source=r["source"],
parent_token=r["parent_token"],
dim_token=r["dim_token"],
)
cache.urd_by_concept.setdefault(r["id"], []).append(edge)
cache.urd_by_concept_dim[(r["id"], r["dim_id"])] = edge
pending = await conn.fetch(
"SELECT DISTINCT concept_id FROM resolution_queue WHERE status = 'pending'"
)
cache.pending_conflicts = {r["concept_id"] for r in pending}
log.info(
"cache warm: %d soas, %d urd edges, %d pending conflicts",
len(cache.soas_by_token),
len(cache.urd_by_concept_dim),
len(cache.pending_conflicts),
)
async def reload_urd_cache(pool: asyncpg.Pool) -> None:
"""Rebuild only URD cache (called after nightly resolution job)."""
cache.urd_by_concept.clear()
cache.urd_by_concept_dim.clear()
async with pool.acquire() as conn:
urd_rows = await conn.fetch(
"""
SELECT u.id, u.parent_id, u.dim_id, u.is_isa, u.confidence, u.source,
p.token AS parent_token, d.token AS dim_token
FROM urd u
INNER JOIN soas p ON p.id = u.parent_id
INNER JOIN soas d ON d.id = u.dim_id
WHERE NOT (u.id = u.parent_id AND u.id = u.dim_id)
"""
)
for r in urd_rows:
edge = UrdEdge(
concept_id=r["id"],
parent_id=r["parent_id"],
dim_id=r["dim_id"],
is_isa=r["is_isa"],
confidence=r["confidence"],
source=r["source"],
parent_token=r["parent_token"],
dim_token=r["dim_token"],
)
cache.urd_by_concept.setdefault(r["id"], []).append(edge)
cache.urd_by_concept_dim[(r["id"], r["dim_id"])] = edge
pending = await conn.fetch(
"SELECT DISTINCT concept_id FROM resolution_queue WHERE status = 'pending'"
)
cache.pending_conflicts = {r["concept_id"] for r in pending}
log.info("urd cache reloaded")
# ---------------------------------------------------------------------------
# SOAS upsert — id always comes from Postgres
# ---------------------------------------------------------------------------
async def get_or_create_soas(pool: asyncpg.Pool, token: str) -> SoasRow:
"""Return cached SoasRow, inserting into Postgres + cache if new."""
if token in cache.soas_by_token:
return cache.soas_by_token[token]
async with pool.acquire() as conn:
row = await conn.fetchrow(
"INSERT INTO soas (token) VALUES ($1) "
"ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token "
"RETURNING id, token, encounter_count, saliency, novelty",
token,
)
soas_row = SoasRow(
id=row["id"],
token=row["token"],
encounter_count=row["encounter_count"],
saliency=row["saliency"],
novelty=row["novelty"],
)
cache.soas_by_token[token] = soas_row
cache.soas_by_id[row["id"]] = token
return soas_row
# ---------------------------------------------------------------------------
# Saliency recalculation (log-scale)
# ---------------------------------------------------------------------------
def recalculate_saliency(encounter_count: int, is_common_english: bool) -> float:
"""
Log-scaled saliency. Common English words are pre-seeded with count=0,
novelty=0 and will always return 0. Domain tokens start at count=1 after
first encounter; saliency grows logarithmically.
"""
if is_common_english or encounter_count <= 0:
return 0.0
return math.log1p(encounter_count)
# ---------------------------------------------------------------------------
# Batch saliency flush
# ---------------------------------------------------------------------------
async def flush_encounter_deltas(pool: asyncpg.Pool) -> None:
"""Flush staged encounter_count deltas to Postgres in one batch UPDATE."""
deltas = cache.drain_deltas()
if not deltas:
return
async with pool.acquire() as conn:
async with conn.transaction():
for soas_id, delta in deltas.items():
token = cache.soas_by_id.get(soas_id, "")
row = cache.soas_by_token.get(token)
new_count = (row.encounter_count if row else 0)
# novelty = 0 for common English words (pre-seeded)
is_common = (row.novelty == 0.0 and row.saliency == 0.0) if row else False
new_saliency = recalculate_saliency(new_count, is_common)
await conn.execute(
"""
UPDATE soas
SET encounter_count = encounter_count + $1,
last_seen = now(),
saliency = $2
WHERE id = $3
""",
delta, new_saliency, soas_id,
)
if row:
row.saliency = new_saliency
log.debug("flushed %d saliency deltas", len(deltas))
# ---------------------------------------------------------------------------
# Config helper
# ---------------------------------------------------------------------------
async def get_config(pool: asyncpg.Pool, key: str, default: str = "") -> str:
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value FROM config WHERE key = $1", key)
return row["value"] if row else default
+210
View File
@@ -0,0 +1,210 @@
"""
LLM client — supports 'claude' and 'openai' providers.
Uses the model configured in the models table (fetched via write_model_id
or resolve_model_id config keys).
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any, Optional
import asyncpg
log = logging.getLogger("festinger.llm")
@dataclass
class ModelConfig:
provider: str
model_name: str
api_key: str
async def get_model_config(pool: asyncpg.Pool, model_id: str) -> Optional[ModelConfig]:
if not model_id:
return None
try:
mid = int(model_id)
except ValueError:
return None
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT provider, model_name, api_key FROM models WHERE id=$1", mid
)
if not row:
return None
return ModelConfig(
provider=row["provider"],
model_name=row["model_name"],
api_key=row["api_key"],
)
async def call_llm(model: ModelConfig, prompt: str) -> str:
"""Call the configured LLM and return the text response."""
if model.provider == "claude":
return await _call_claude(model, prompt)
elif model.provider == "openai":
return await _call_openai(model, prompt)
else:
raise ValueError(f"Unknown LLM provider: {model.provider!r}")
async def _call_claude(model: ModelConfig, prompt: str) -> str:
import anthropic
client = anthropic.AsyncAnthropic(api_key=model.api_key)
message = await client.messages.create(
model=model.model_name,
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return message.content[0].text
async def _call_openai(model: ModelConfig, prompt: str) -> str:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=model.api_key)
response = await client.chat.completions.create(
model=model.model_name,
messages=[{"role": "user", "content": prompt}],
max_tokens=1024,
)
return response.choices[0].message.content or ""
# ---------------------------------------------------------------------------
# Structured prompts
# ---------------------------------------------------------------------------
WRITE_PROMPT_TEMPLATE = """You are a knowledge extraction assistant.
Given the concept: "{concept}"
And these known dimensions: {dimensions}
Return a JSON array of triples describing what you know about this concept.
Each triple must have these fields:
- "parent": the containing concept (string)
- "dimension": one of the known dimensions above, or a new specific one if none fit
- "is_isa": true if this is a classification (ISA), false if membership/containment (ISPART)
- "confidence": 0.0 to 1.0
Return ONLY the JSON array. No explanation. Example:
[
{{"parent": "software-repository", "dimension": "type", "is_isa": true, "confidence": 0.9}},
{{"parent": "glitch-university", "dimension": "membership", "is_isa": false, "confidence": 0.85}}
]
"""
@dataclass
class LLMTriple:
parent: str
dimension: str
is_isa: bool
confidence: float
def parse_llm_triples(response: str) -> list[LLMTriple]:
"""Parse JSON array of triples from LLM response."""
try:
# Find the JSON array in the response
start = response.find("[")
end = response.rfind("]") + 1
if start == -1 or end == 0:
return []
data = json.loads(response[start:end])
triples = []
for item in data:
if not isinstance(item, dict):
continue
triples.append(LLMTriple(
parent=str(item.get("parent", "")).strip().lower(),
dimension=str(item.get("dimension", "type")).strip().lower(),
is_isa=bool(item.get("is_isa", True)),
confidence=float(item.get("confidence", 0.7)),
))
return triples
except (json.JSONDecodeError, ValueError) as e:
log.warning("failed to parse LLM triples: %s", e)
return []
RESOLVE_ISA_ISA_PROMPT = """You are a knowledge graph resolution assistant.
Concept: "{concept}"
Current fact: "{concept}" is a "{existing_parent}" in dimension "{dimension}"
Conflicting fact: "{concept}" is a "{incoming_parent}" in dimension "{dimension}"
Both facts appear to be simultaneously true. The dimension "{dimension}" is too coarse.
Propose two more specific dimension names to replace it:
- existing_dimension: the dimension where "{concept}" as "{existing_parent}" is valid
- new_dimension: the dimension where "{concept}" as "{incoming_parent}" is valid
Known dimensions for reference: {known_dimensions}
Return ONLY a JSON object:
{{"decision": "decompose", "existing_dimension": "...", "new_dimension": "...", "reasoning": "..."}}
If the conflicting fact is clearly wrong, return:
{{"decision": "dismiss", "reasoning": "..."}}
"""
RESOLVE_ISPART_ISPART_PROMPT = """You are a knowledge graph resolution assistant.
Concept: "{concept}"
Current fact: "{concept}" belongs to "{existing_parent}" in dimension "{dimension}"
Conflicting fact: "{concept}" belongs to "{incoming_parent}" in dimension "{dimension}"
These facts contradict each other — a thing can only belong to one place per dimension.
Return ONLY a JSON object with your decision:
{{"decision": "update", "reasoning": "..."}} — if the incoming fact is more current/correct
{{"decision": "dismiss", "reasoning": "..."}} — if the existing fact is still correct
"""
RESOLVE_MISCLASS_PROMPT = """You are a knowledge graph resolution assistant.
Concept: "{concept}"
A relationship was classified inconsistently:
- Existing: "{concept}" ISA "{existing_parent}" in dimension "{dimension}"
- Incoming: "{concept}" ISPART "{incoming_parent}" in dimension "{dimension}"
The incoming fact uses a different relation type. Suggest the correct dimension for the incoming fact.
Known dimensions: {known_dimensions}
Return ONLY a JSON object:
{{"decision": "reclassify", "correct_dimension": "...", "reasoning": "..."}}
"""
@dataclass
class ResolutionDecision:
decision: str # 'decompose', 'update', 'dismiss', 'reclassify'
existing_dimension: str = ""
new_dimension: str = ""
correct_dimension: str = ""
reasoning: str = ""
def parse_resolution_decision(response: str) -> Optional[ResolutionDecision]:
try:
start = response.find("{")
end = response.rfind("}") + 1
if start == -1 or end == 0:
return None
data = json.loads(response[start:end])
return ResolutionDecision(
decision=str(data.get("decision", "dismiss")).lower(),
existing_dimension=str(data.get("existing_dimension", "")).lower(),
new_dimension=str(data.get("new_dimension", "")).lower(),
correct_dimension=str(data.get("correct_dimension", "")).lower(),
reasoning=str(data.get("reasoning", "")),
)
except (json.JSONDecodeError, ValueError) as e:
log.warning("failed to parse resolution decision: %s", e)
return None
@@ -0,0 +1,93 @@
"""
Loop detector — extracted from the original proxy.py POC.
Session-scoped exact-match repetition detection with configurable mitigations.
"""
from __future__ import annotations
import hashlib
import json
import logging
from collections import defaultdict, deque
log = logging.getLogger("festinger.loop")
# Key: (model, session_fingerprint) → deque of response hashes
_history: dict[tuple, deque] = defaultdict(lambda: deque(maxlen=20))
_consecutive: dict[tuple, int] = defaultdict(int)
_last_hash: dict[tuple, str] = {}
def session_key(model: str, messages: list[dict]) -> tuple[str, str]:
system_msgs = [m for m in messages if m.get("role") == "system"]
if system_msgs:
fingerprint = hashlib.sha256(
system_msgs[0].get("content", "")[:512].encode()
).hexdigest()[:16]
else:
non_assistant = [m for m in messages if m.get("role") != "assistant"]
blob = json.dumps(non_assistant, sort_keys=True)
fingerprint = hashlib.sha256(blob.encode()).hexdigest()[:16]
return (model, fingerprint)
def hash_response(text: str) -> str:
return hashlib.sha256(text.strip().encode()).hexdigest()
def record_and_check(session: tuple, text: str, min_length: int) -> int:
if len(text.strip()) < min_length:
return 1
h = hash_response(text)
if _last_hash.get(session) == h:
_consecutive[session] += 1
else:
_consecutive[session] = 1
_last_hash[session] = h
_history[session].append(h)
return _consecutive[session]
def apply_mitigations(
request_body: dict,
count: int,
config: dict,
) -> tuple[dict, str | None]:
mitigations = config.get("mitigations", [])
override: str | None = None
for m in mitigations:
if not m.get("enabled", True):
continue
if count < m.get("trigger_count", 2):
continue
strategy = m["strategy"]
if strategy == "temperature_boost":
opts = request_body.setdefault("options", {})
current = opts.get("temperature", 0.7)
boosted = min(current + m.get("boost_amount", 0.35), m.get("max_temperature", 1.4))
opts["temperature"] = boosted
log.warning("mitigation=temperature_boost %.2f%.2f (count=%d)", current, boosted, count)
elif strategy == "forbidden_action":
msg = m.get("injection_message", "STOP. Try something completely different.").format(count=count)
request_body.setdefault("messages", []).append({"role": "user", "content": msg})
log.warning("mitigation=forbidden_action injected (count=%d)", count)
elif strategy == "history_truncation":
messages = request_body.get("messages", [])
truncate = m.get("truncate_turns", 6)
system_msgs = [m_ for m_ in messages if m_.get("role") == "system"]
non_system = [m_ for m_ in messages if m_.get("role") != "system"]
trimmed = non_system[:-truncate] if len(non_system) > truncate else non_system[-2:]
request_body["messages"] = system_msgs + trimmed
log.warning("mitigation=history_truncation dropped %d turns (count=%d)", truncate, count)
elif strategy == "circuit_breaker":
override = m.get("response_message", "[LOOP DETECTOR] Halted due to repeated responses.")
log.error("mitigation=circuit_breaker TRIGGERED (count=%d)", count)
break
return request_body, override
+821
View File
@@ -0,0 +1,821 @@
"""
Festinger — main FastAPI application.
Routes:
POST /api/chat Ollama-compatible chat (loop detection + recollection injection)
POST /api/generate Ollama-compatible generate
POST /v1/messages Anthropic Messages API proxy (loop detection + recollection)
POST /v1/chat/completions OpenAI-compatible proxy (loop detection + recollection)
POST /iknowthat Manual write path (gutask iknowthat)
POST /resolve/run Manually trigger nightly resolution job
POST /reload Reload URD cache (called by resolution job)
GET /health Health + stats
GET /conflicts Pending and recently resolved conflicts
GET /admin Minimal admin UI
* /v1/{path} Passthrough to upstream Anthropic
* /{path} Passthrough to upstream Ollama
"""
from __future__ import annotations
import json
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import httpx
import yaml
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse
from . import cache
from .db import (
close_pool, get_config, get_or_create_soas,
get_pool, init_schema, bootstrap_dimensions,
bootstrap_english_dictionary, warm_cache, reload_urd_cache,
flush_encounter_deltas,
)
from .loop_detector import apply_mitigations, record_and_check, session_key
from .cue_scanner import scan_cues
from .recollection import build_recollection_block, inject_recollection
from .resolution_job import run_resolution_job, last_run_timestamp
from .tokenizer import tokenize
from .write_queue import enqueue_concept, enqueue_cue, start_worker, stop_worker
from .urd_writer import InsertRequest, insert_urd_edge
from .wordnet import import_wordnet, CITATION as WORDNET_CITATION
from .test_scenarios import SCENARIOS, seed_scenario, reset_scenario
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
)
log = logging.getLogger("festinger")
CONFIG_PATH = Path(__file__).parent.parent / "config.yaml"
def load_yaml_config() -> dict:
with open(CONFIG_PATH) as f:
return yaml.safe_load(f)
# ---------------------------------------------------------------------------
# Lifespan — startup / shutdown
# ---------------------------------------------------------------------------
_scheduler = AsyncIOScheduler()
@asynccontextmanager
async def lifespan(app: FastAPI):
cfg = load_yaml_config()
dsn = os.environ.get("POSTGRES_DSN", cfg.get("postgres_dsn", ""))
pool = await get_pool(dsn)
app.state.pool = pool
app.state.yaml_config = cfg
await init_schema(pool)
await bootstrap_dimensions(pool)
await bootstrap_english_dictionary(pool)
await warm_cache(pool)
await start_worker(pool)
# Schedule saliency flush every 30 s
_scheduler.add_job(flush_encounter_deltas, "interval", seconds=30, args=[pool], id="saliency_flush")
# Schedule nightly resolution job from config table
resolution_cron = await get_config(pool, "resolution_schedule", "0 2 * * *")
cron_parts = resolution_cron.split()
if len(cron_parts) == 5:
minute, hour, day, month, dow = cron_parts
_scheduler.add_job(
run_resolution_job, "cron",
minute=minute, hour=hour, day=day, month=month, day_of_week=dow,
args=[pool], id="nightly_resolution",
)
_scheduler.start()
log.info("festinger ready")
yield
_scheduler.shutdown(wait=False)
await stop_worker()
await close_pool()
app = FastAPI(title="Festinger", lifespan=lifespan)
# ---------------------------------------------------------------------------
# Ollama forwarding helpers
# ---------------------------------------------------------------------------
async def call_ollama(path: str, body: dict, upstream: str) -> tuple[str, dict]:
body = dict(body)
body["stream"] = False
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{upstream}{path}", json=body)
r.raise_for_status()
data = r.json()
if path == "/api/chat":
text = data.get("message", {}).get("content", "")
else:
text = data.get("response", "")
return text, data
# ---------------------------------------------------------------------------
# Anthropic forwarding helpers
# ---------------------------------------------------------------------------
def _relay_headers(request: Request, keep: tuple[str, ...]) -> dict[str, str]:
"""Extract a safe subset of request headers to forward upstream."""
return {
k: v for k, v in request.headers.items()
if k.lower() in keep
}
ANTHROPIC_RELAY_HEADERS = (
"x-api-key",
"anthropic-version",
"anthropic-beta",
"content-type",
)
OPENAI_RELAY_HEADERS = (
"authorization",
"content-type",
"openai-organization",
"openai-project",
)
async def call_anthropic(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
"""
Forward a request to the Anthropic Messages API (non-streaming).
Returns (assistant_text, raw_response_dict).
"""
body = dict(body)
body["stream"] = False
# Anthropic requires anthropic-version header; add default if caller omitted it
if "anthropic-version" not in {k.lower() for k in headers}:
headers = {**headers, "anthropic-version": "2023-06-01"}
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{upstream}/v1/messages",
json=body,
headers=headers,
)
r.raise_for_status()
data = r.json()
# Extract text from Anthropic content blocks
text = ""
for block in data.get("content", []):
if block.get("type") == "text":
text += block.get("text", "")
return text, data
async def call_openai(body: dict, upstream: str, headers: dict) -> tuple[str, dict]:
"""
Forward a request to an OpenAI-compatible chat completions endpoint (non-streaming).
Returns (assistant_text, raw_response_dict).
"""
body = dict(body)
body["stream"] = False
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(
f"{upstream}/v1/chat/completions",
json=body,
headers=headers,
)
r.raise_for_status()
data = r.json()
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
return text, data
# ---------------------------------------------------------------------------
# Text extraction helpers (unified across API formats)
# ---------------------------------------------------------------------------
def extract_prompt_text(body: dict, path: str) -> str:
"""Extract a flat string from a request body for saliency processing."""
if path in ("/api/chat", "/v1/chat/completions"):
messages = body.get("messages", [])
parts = []
for m in messages:
content = m.get("content", "")
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
# Anthropic-style content blocks
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
# Include top-level system field (Anthropic format)
if body.get("system"):
parts.insert(0, body["system"])
return " ".join(parts)
if path == "/v1/messages":
return extract_prompt_text(body, "/v1/chat/completions")
return body.get("prompt", "")
def inject_recollection_anthropic(body: dict, block: str) -> dict:
"""
Inject a recollection block into an Anthropic Messages API request.
Anthropic uses a top-level 'system' string field rather than a system message.
"""
body = dict(body)
existing = body.get("system") or ""
body["system"] = block + ("\n\n" + existing if existing else "")
return body
# ---------------------------------------------------------------------------
# Saliency + recollection pipeline
# ---------------------------------------------------------------------------
async def process_prompt(body: dict, path: str, pool, cfg: dict) -> dict:
"""
Run the saliency + recollection pipeline over the prompt.
Returns a (possibly modified) body dict with the recollection block injected.
"""
read_threshold = float(await get_config(pool, "saliency_read_threshold", "0.5"))
write_threshold = float(await get_config(pool, "saliency_write_threshold", "1.2"))
conf_floor = float(await get_config(pool, "recollection_confidence_floor", "0.6"))
recency_days = int(await get_config(pool, "recollection_recency_days", "90"))
prompt_text = extract_prompt_text(body, path)
if not prompt_text.strip():
return body
# 1. Scan for explicit relationship cues (bypass threshold)
cues = scan_cues(prompt_text)
for cue in cues:
await enqueue_cue(cue)
# 2. Tokenise + update saliency
tokens = tokenize(prompt_text)
salient_for_read: list[int] = []
salient_for_write: list[str] = []
for token in tokens:
soas_row = cache.soas_by_token.get(token)
if soas_row is None:
# New token — get_or_create happens in background via queue when needed
continue # unknown token — skip saliency for now; write queue handles creation
cache.record_encounter(soas_row.id)
if soas_row.saliency >= read_threshold:
salient_for_read.append(soas_row.id)
if soas_row.saliency >= write_threshold and soas_row.novelty < 1.0:
salient_for_write.append(token)
for token in salient_for_write:
await enqueue_concept(token)
if not salient_for_read:
return body
# 3. Build recollection block
block = build_recollection_block(salient_for_read, conf_floor, recency_days)
if not block:
return body
# 4. Inject into messages
if path == "/api/chat" or path == "/v1/chat/completions":
body = dict(body)
body["messages"] = inject_recollection(body.get("messages", []), block)
elif path == "/v1/messages":
body = inject_recollection_anthropic(body, block)
# /api/generate uses a flat prompt string — prepend there
elif path == "/api/generate":
body = dict(body)
body["prompt"] = block + "\n\n" + body.get("prompt", "")
return body
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.post("/api/chat")
async def chat(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
body = await request.json()
model = body.get("model", "unknown")
upstream = cfg["upstream_ollama"]
min_len = cfg["detection"]["min_length"]
body = await process_prompt(body, "/api/chat", pool, cfg)
text, raw = await call_ollama("/api/chat", body, upstream)
sess = session_key(model, body.get("messages", []))
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
raw["message"] = {"role": "assistant", "content": override}
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_ollama("/api/chat", body, upstream)
record_and_check(sess, text, min_len)
raw["message"] = {"role": "assistant", "content": text}
return Response(content=json.dumps(raw), media_type="application/json")
@app.post("/api/generate")
async def generate(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
body = await request.json()
model = body.get("model", "unknown")
upstream = cfg["upstream_ollama"]
min_len = cfg["detection"]["min_length"]
body = await process_prompt(body, "/api/generate", pool, cfg)
messages = [{"role": "user", "content": body.get("prompt", "")}]
sess = session_key(model, messages)
text, raw = await call_ollama("/api/generate", body, upstream)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
raw["response"] = override
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_ollama("/api/generate", body, upstream)
record_and_check(sess, text, min_len)
raw["response"] = text
return Response(content=json.dumps(raw), media_type="application/json")
# ---------------------------------------------------------------------------
# Anthropic Messages API (POST /v1/messages)
# ---------------------------------------------------------------------------
@app.post("/v1/messages")
async def anthropic_messages(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
body = await request.json()
model = body.get("model", "unknown")
upstream = cfg["upstream_anthropic"]
min_len = cfg["detection"]["min_length"]
headers = _relay_headers(request, ANTHROPIC_RELAY_HEADERS)
# Ensure anthropic-version is present
if "anthropic-version" not in {k.lower() for k in headers}:
headers["anthropic-version"] = "2023-06-01"
body = await process_prompt(body, "/v1/messages", pool, cfg)
# Use messages list as session key (same logic as /api/chat)
messages = body.get("messages", [])
sess = session_key(model, messages)
text, raw = await call_anthropic(body, upstream, headers)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
# Return a minimal Anthropic-format response with the override message
raw["content"] = [{"type": "text", "text": override}]
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_anthropic(body, upstream, headers)
record_and_check(sess, text, min_len)
return Response(content=json.dumps(raw), media_type="application/json")
# ---------------------------------------------------------------------------
# OpenAI-compatible chat completions (POST /v1/chat/completions)
# ---------------------------------------------------------------------------
@app.post("/v1/chat/completions")
async def openai_chat_completions(request: Request) -> Response:
cfg = request.app.state.yaml_config
pool = request.app.state.pool
body = await request.json()
model = body.get("model", "unknown")
upstream = cfg["upstream_openai"]
min_len = cfg["detection"]["min_length"]
headers = _relay_headers(request, OPENAI_RELAY_HEADERS)
body = await process_prompt(body, "/v1/chat/completions", pool, cfg)
messages = body.get("messages", [])
sess = session_key(model, messages)
text, raw = await call_openai(body, upstream, headers)
count = record_and_check(sess, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, sess[1], count)
body, override = apply_mitigations(body, count, cfg)
if override is not None:
if raw.get("choices"):
raw["choices"][0]["message"]["content"] = override
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_openai(body, upstream, headers)
record_and_check(sess, text, min_len)
return Response(content=json.dumps(raw), media_type="application/json")
# ---------------------------------------------------------------------------
# /iknowthat — manual write path
# ---------------------------------------------------------------------------
@app.post("/iknowthat")
async def iknowthat(request: Request) -> dict:
"""
Parse and insert a manual fact.
Body: {"fact": "gnommoweb -isa repo in context of glitch_university"}
"""
pool = request.app.state.pool
data = await request.json()
fact = data.get("fact", "").strip()
if not fact:
return {"error": "fact is required"}
# Parse: "{subject} -isa|-ispart {parent} [in context of {dimension}]"
import re
m = re.match(
r"^(?P<subj>\S+)\s+(?P<rel>-isa|-ispart)\s+(?P<parent>\S+)"
r"(?:\s+in\s+context\s+of\s+(?P<dim>\S+))?$",
fact, re.IGNORECASE,
)
if not m:
return {"error": f"could not parse fact: {fact!r}. "
"Expected: '<subject> -isa|-ispart <parent> [in context of <dimension>]'"}
subj = m.group("subj").lower()
is_isa = m.group("rel").lower() == "-isa"
parent = m.group("parent").lower()
dim = (m.group("dim") or ("type" if is_isa else "membership")).lower()
subj_row = await get_or_create_soas(pool, subj)
parent_row = await get_or_create_soas(pool, parent)
dim_row = await get_or_create_soas(pool, dim)
req = InsertRequest(
concept_id=subj_row.id,
parent_id=parent_row.id,
dim_id=dim_row.id,
is_isa=is_isa,
confidence=1.0,
source="gutask",
)
collision = await insert_urd_edge(pool, req, priority=True)
if collision:
return {
"status": "collision",
"collision_type": collision.collision_type,
"fact": fact,
"message": "Conflict queued for nightly resolution (priority). "
"Current world model unchanged.",
}
return {
"status": "inserted",
"fact": fact,
"subject": subj,
"parent": parent,
"dimension": dim,
"is_isa": is_isa,
}
# ---------------------------------------------------------------------------
# /resolve/run — manually trigger resolution job
# ---------------------------------------------------------------------------
@app.post("/resolve/run")
async def resolve_run(request: Request) -> dict:
pool = request.app.state.pool
result = await run_resolution_job(pool)
return result
# ---------------------------------------------------------------------------
# /wordnet/import — bulk-load WordNet lemmas into SOAS
# ---------------------------------------------------------------------------
@app.post("/wordnet/import")
async def wordnet_import(request: Request) -> dict:
"""
Import Princeton WordNet 3.x lemmas into SOAS (saliency=0, novelty=0).
Idempotent — already-present tokens are skipped.
Can take 515 seconds for a full import (~130k lemmas).
"""
pool = request.app.state.pool
result = await import_wordnet(pool)
return result
# ---------------------------------------------------------------------------
# /reload — reload URD cache (used after resolution job)
# ---------------------------------------------------------------------------
@app.post("/reload")
async def reload(request: Request) -> dict:
pool = request.app.state.pool
await reload_urd_cache(pool)
return {"status": "ok", "urd_edges": len(cache.urd_by_concept_dim)}
# ---------------------------------------------------------------------------
# /health
# ---------------------------------------------------------------------------
@app.get("/health")
async def health(request: Request) -> dict:
cfg = request.app.state.yaml_config
return {
"status": "ok",
"upstream": cfg["upstream_ollama"],
"active_loop_sessions": 0, # loop detector is stateful in-process
"soas_tokens": len(cache.soas_by_token),
"urd_edges": len(cache.urd_by_concept_dim),
"pending_conflicts": len(cache.pending_conflicts),
"last_resolution_run": last_run_timestamp(),
"timestamp": int(time.time()),
}
# ---------------------------------------------------------------------------
# /conflicts — expose resolution queue
# ---------------------------------------------------------------------------
@app.get("/conflicts")
async def conflicts(request: Request) -> dict:
pool = request.app.state.pool
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, concept_id, existing_parent_id, incoming_parent_id,
dim_id, collision_type, status, resolution, priority,
created_at, resolved_at
FROM resolution_queue
ORDER BY status ASC, created_at DESC
LIMIT 100
"""
)
def format_row(r):
return {
"id": r["id"],
"concept": cache.soas_by_id.get(r["concept_id"], str(r["concept_id"])),
"existing_parent": cache.soas_by_id.get(r["existing_parent_id"], "?"),
"incoming_parent": cache.soas_by_id.get(r["incoming_parent_id"], "?"),
"dimension": cache.soas_by_id.get(r["dim_id"], "?"),
"collision_type": r["collision_type"],
"status": r["status"],
"resolution": r["resolution"],
"priority": r["priority"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
"resolved_at": r["resolved_at"].isoformat() if r["resolved_at"] else None,
}
return {"conflicts": [format_row(r) for r in rows]}
# ---------------------------------------------------------------------------
# /test — scenario seeding and reset (for integration testing)
# ---------------------------------------------------------------------------
@app.get("/test/scenarios")
async def list_scenarios() -> dict:
"""List all available test scenarios."""
return {
"scenarios": {
sid: {
"name": sc.name,
"trigger_fact": sc.trigger_fact,
"expected_collision": sc.expected_collision,
"expected_resolution": sc.expected_resolution,
}
for sid, sc in SCENARIOS.items()
}
}
@app.post("/test/seed/{scenario_id}")
async def test_seed(scenario_id: str, request: Request) -> dict:
"""
Seed the database with pre-defined world-model state for a named scenario.
After seeding, POST the trigger fact to /iknowthat to cause the collision.
Available scenarios: A (misclassification), B (ISA+ISA decompose), C (ISPART+ISPART)
"""
pool = request.app.state.pool
return await seed_scenario(pool, scenario_id)
@app.post("/test/reset/{scenario_id}")
async def test_reset(scenario_id: str, request: Request) -> dict:
"""
Remove URD edges, SOAS saliency boosts, and resolution queue rows
introduced by a scenario seed. Use before re-running a scenario.
"""
pool = request.app.state.pool
return await reset_scenario(pool, scenario_id)
# ---------------------------------------------------------------------------
# /admin — minimal HTML UI
# ---------------------------------------------------------------------------
ADMIN_HTML = """<!DOCTYPE html>
<html>
<head>
<title>Festinger Admin</title>
<style>
body {{ font-family: monospace; max-width: 960px; margin: 40px auto; padding: 0 20px; color: #222; }}
h1 {{ font-size: 1.4em; margin-bottom: 0.2em; }}
h2 {{ font-size: 1.1em; margin-top: 2em; border-bottom: 1px solid #ddd; padding-bottom: 4px; }}
.subtitle {{ color: #666; font-size: 0.85em; margin-bottom: 1.5em; }}
.stats {{ display: flex; gap: 2em; flex-wrap: wrap; margin: 1em 0; }}
.stat {{ background: #f8f8f8; border: 1px solid #e0e0e0; border-radius: 4px; padding: 12px 20px; min-width: 130px; }}
.stat-label {{ font-size: 0.75em; color: #666; text-transform: uppercase; letter-spacing: 0.05em; }}
.stat-value {{ font-size: 1.8em; font-weight: bold; margin-top: 2px; }}
.actions {{ display: flex; gap: 1em; flex-wrap: wrap; margin: 1em 0; }}
button {{ padding: 8px 18px; cursor: pointer; border: 1px solid #aaa; background: #fff; border-radius: 3px; font-family: monospace; }}
button:hover {{ background: #f0f0f0; }}
button.primary {{ background: #1a1a2e; color: #fff; border-color: #1a1a2e; }}
button.primary:hover {{ background: #2a2a4e; }}
button:disabled {{ opacity: 0.5; cursor: not-allowed; }}
pre {{ background: #f4f4f4; border: 1px solid #e0e0e0; border-radius: 3px; padding: 1em; overflow: auto; font-size: 0.85em; max-height: 400px; }}
.status-ok {{ color: #2a7a2a; }}
.status-err {{ color: #b00; }}
footer {{ margin-top: 3em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 0.78em; color: #888; }}
footer a {{ color: #888; }}
</style>
</head>
<body>
<h1>Festinger</h1>
<p class="subtitle">Ollama-compatible inference middleware — loop detection &amp; Recollections world model</p>
<h2>World model stats</h2>
<div class="stats" id="stats">
<div class="stat"><div class="stat-label">SOAS tokens</div><div class="stat-value" id="s-soas">…</div></div>
<div class="stat"><div class="stat-label">URD edges</div><div class="stat-value" id="s-urd">…</div></div>
<div class="stat"><div class="stat-label">Pending conflicts</div><div class="stat-value" id="s-conflicts">…</div></div>
<div class="stat"><div class="stat-label">Last resolution</div><div class="stat-value" style="font-size:0.85em" id="s-lastrun">…</div></div>
</div>
<h2>Actions</h2>
<div class="actions">
<button class="primary" onclick="runResolution(this)">Run conflict resolution now</button>
<button onclick="runWordnetImport(this)">Import WordNet lemmas</button>
</div>
<pre id="result" style="display:none"></pre>
<h2>Pending conflicts</h2>
<pre id="conflicts-pre">Loading…</pre>
<footer>
<strong>Vocabulary source:</strong>
Princeton University &ldquo;About WordNet.&rdquo; <em>WordNet.</em> Princeton University. 2010.
<a href="https://wordnet.princeton.edu/" target="_blank">https://wordnet.princeton.edu/</a>
&mdash; used to pre-seed the SOAS concept vocabulary at saliency&nbsp;0 (common English baseline).
</footer>
<script>
async function loadStats() {{
const r = await fetch('/health');
const d = await r.json();
document.getElementById('s-soas').textContent = d.soas_tokens.toLocaleString();
document.getElementById('s-urd').textContent = d.urd_edges.toLocaleString();
document.getElementById('s-conflicts').textContent = d.pending_conflicts;
document.getElementById('s-lastrun').textContent = d.last_resolution_run
? d.last_resolution_run.replace('T', ' ').slice(0, 19)
: 'never';
}}
async function loadConflicts() {{
const r = await fetch('/conflicts');
const d = await r.json();
const pending = d.conflicts.filter(c => c.status === 'pending');
const el = document.getElementById('conflicts-pre');
el.textContent = pending.length
? JSON.stringify(pending, null, 2)
: '(none)';
}}
function showResult(text, ok) {{
const el = document.getElementById('result');
el.style.display = 'block';
el.className = ok ? 'status-ok' : 'status-err';
el.textContent = text;
}}
async function runResolution(btn) {{
btn.disabled = true;
showResult('Running resolution job…', true);
try {{
const r = await fetch('/resolve/run', {{method: 'POST'}});
const d = await r.json();
showResult(JSON.stringify(d, null, 2), r.ok);
await loadStats();
await loadConflicts();
}} catch(e) {{
showResult('Error: ' + e.message, false);
}} finally {{
btn.disabled = false;
}}
}}
async function runWordnetImport(btn) {{
btn.disabled = true;
showResult('Importing WordNet lemmas — this may take 1020 seconds…', true);
try {{
const r = await fetch('/wordnet/import', {{method: 'POST'}});
const d = await r.json();
showResult(JSON.stringify(d, null, 2), r.ok && !d.error);
await loadStats();
}} catch(e) {{
showResult('Error: ' + e.message, false);
}} finally {{
btn.disabled = false;
}}
}}
loadStats();
loadConflicts();
</script>
</body>
</html>
"""
@app.get("/admin", response_class=HTMLResponse)
async def admin() -> str:
return ADMIN_HTML.format()
# ---------------------------------------------------------------------------
# Passthrough — everything else forwarded to upstream Ollama
# ---------------------------------------------------------------------------
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"])
async def passthrough(path: str, request: Request) -> Response:
cfg = request.app.state.yaml_config
# Route /v1/* to Anthropic; everything else (including /api/*) to Ollama
if path.startswith("v1/"):
upstream = cfg["upstream_anthropic"]
relay_headers = ANTHROPIC_RELAY_HEADERS
else:
upstream = cfg["upstream_ollama"]
relay_headers = None
body = await request.body()
if relay_headers:
headers = _relay_headers(request, relay_headers)
else:
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.request(
request.method,
f"{upstream}/{path}",
content=body,
headers=headers,
)
return Response(
content=r.content,
status_code=r.status_code,
media_type=r.headers.get("content-type"),
)
+126
View File
@@ -0,0 +1,126 @@
"""
Recollection engine — read path.
For each salient concept found in an intercepted prompt:
- Query URD edges (from in-memory cache, filtered by confidence + recency)
- Render hit or zero-hit block
- Inject the <recollection> block into the prompt before forwarding to Ollama
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional
from . import cache
from .cache import SoasRow, UrdEdge
log = logging.getLogger("festinger.recollection")
ZERO_HIT_TEMPLATE = (
"? {concept}: no recollection. If not a typo, store it before proceeding:\n"
" gutask iknowthat '{concept} -isa <parent> in context of <dimension>'\n"
" gutask iknowthat '{concept} -ispart <system> in context of <dimension>'"
)
# ---------------------------------------------------------------------------
# Query (reads from in-memory cache)
# ---------------------------------------------------------------------------
def query_edges(
concept_id: int,
confidence_floor: float,
recency_days: int,
) -> list[UrdEdge]:
"""
Return URD edges for *concept_id* that pass confidence + recency filters.
All reads are pure in-memory — zero network.
Note: we store last_confirmed as a datetime in the edge when warming the
cache. For simplicity the cache stores it as a string from Postgres;
the recency filter is intentionally lenient when last_confirmed is absent.
"""
edges = cache.urd_by_concept.get(concept_id, [])
if not edges:
return []
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=recency_days)
result = []
for e in edges:
if e.confidence < confidence_floor:
continue
result.append(e)
return result
# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------
def render_hit(concept_token: str, edges: list[UrdEdge], concept_id: int) -> str:
"""
Render one concept's recollection line:
gnommoweb: [type] repo [membership] glitch_university
Pending-conflict dimensions get a '?' suffix on the dim token.
"""
has_pending = concept_id in cache.pending_conflicts
parts = []
for e in edges:
dim_label = e.dim_token
if has_pending:
dim_label += "?"
parts.append(f"[{dim_label}] {e.parent_token}")
return f"{concept_token}: {' '.join(parts)}"
def render_zero_hit(concept_token: str) -> str:
return ZERO_HIT_TEMPLATE.format(concept=concept_token)
def build_recollection_block(
salient_concept_ids: list[int],
confidence_floor: float,
recency_days: int,
) -> Optional[str]:
"""
Build the full <recollection> block for a list of above-threshold concept IDs.
Returns None if there is nothing to say.
"""
lines: list[str] = []
for cid in salient_concept_ids:
token = cache.soas_by_id.get(cid, str(cid))
edges = query_edges(cid, confidence_floor, recency_days)
if edges:
lines.append(render_hit(token, edges, cid))
else:
lines.append(render_zero_hit(token))
if not lines:
return None
body = "\n".join(lines)
return f"<recollection>\n{body}\n</recollection>"
# ---------------------------------------------------------------------------
# Prompt injection
# ---------------------------------------------------------------------------
def inject_recollection(messages: list[dict], block: str) -> list[dict]:
"""
Prepend the recollection block to the first system message.
If no system message exists, insert one at position 0.
Returns a new messages list (does not mutate the input).
"""
messages = list(messages)
for i, msg in enumerate(messages):
if msg.get("role") == "system":
messages[i] = dict(msg)
messages[i]["content"] = block + "\n\n" + (msg.get("content") or "")
return messages
# No system message — insert one
messages.insert(0, {"role": "system", "content": block})
return messages
@@ -0,0 +1,230 @@
"""
Nightly resolution job — drain the resolution queue via cloud LLM.
For each pending conflict:
- ISA+ISA → decompose (two new dimensions) or dismiss
- ISPART+ISPART → update (swap to incoming) or dismiss
- misclassification → reclassify (insert in correct dimension)
Triggered by APScheduler on the cron schedule in config table,
or manually via POST /resolve/run.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
import asyncpg
from . import cache
from .db import get_or_create_soas, get_config, reload_urd_cache
from .llm_client import (
ModelConfig, get_model_config, call_llm,
RESOLVE_ISA_ISA_PROMPT, RESOLVE_ISPART_ISPART_PROMPT,
RESOLVE_MISCLASS_PROMPT, parse_resolution_decision,
)
from .urd_writer import InsertRequest, insert_urd_edge
log = logging.getLogger("festinger.resolution")
_last_run: datetime | None = None
async def run_resolution_job(pool: asyncpg.Pool) -> dict:
"""
Process all pending resolution queue items.
Returns a summary dict.
"""
global _last_run
log.info("resolution job starting")
resolve_model_id = await get_config(pool, "resolve_model_id")
if not resolve_model_id:
log.warning("no resolve_model_id configured — resolution job aborted")
return {"status": "aborted", "reason": "no resolve_model_id"}
model = await get_model_config(pool, resolve_model_id)
if not model:
log.warning("resolve_model_id=%s not found in models table", resolve_model_id)
return {"status": "aborted", "reason": "model not found"}
async with pool.acquire() as conn:
items = await conn.fetch(
"""
SELECT id, concept_id, existing_parent_id, incoming_parent_id,
dim_id, collision_type
FROM resolution_queue
WHERE status = 'pending'
ORDER BY priority DESC, created_at ASC
"""
)
counts = {"decompose": 0, "update": 0, "dismiss": 0, "reclassify": 0, "error": 0}
for item in items:
try:
outcome = await _resolve_item(pool, model, item)
counts[outcome] = counts.get(outcome, 0) + 1
except Exception as e:
log.exception("resolution error for queue item %d: %s", item["id"], e)
counts["error"] += 1
# Reload URD cache after all resolutions
await reload_urd_cache(pool)
_last_run = datetime.now(tz=timezone.utc)
log.info("resolution job complete: %s", counts)
return {"status": "ok", "counts": counts, "processed": len(items)}
async def _resolve_item(pool: asyncpg.Pool, model: ModelConfig, item) -> str:
concept_token = cache.soas_by_id.get(item["concept_id"], str(item["concept_id"]))
existing_parent = cache.soas_by_id.get(item["existing_parent_id"], "?")
incoming_parent = cache.soas_by_id.get(item["incoming_parent_id"], "?")
dim_token = cache.soas_by_id.get(item["dim_id"], "?")
seed_dims = ["type", "membership", "runs-on", "tech", "owned-by", "geography"]
known_dims_str = ", ".join(seed_dims)
collision_type = item["collision_type"]
if collision_type == "isa_isa":
prompt = RESOLVE_ISA_ISA_PROMPT.format(
concept=concept_token,
existing_parent=existing_parent,
incoming_parent=incoming_parent,
dimension=dim_token,
known_dimensions=known_dims_str,
)
elif collision_type == "ispart_ispart":
prompt = RESOLVE_ISPART_ISPART_PROMPT.format(
concept=concept_token,
existing_parent=existing_parent,
incoming_parent=incoming_parent,
dimension=dim_token,
)
else: # misclassification
prompt = RESOLVE_MISCLASS_PROMPT.format(
concept=concept_token,
existing_parent=existing_parent,
incoming_parent=incoming_parent,
dimension=dim_token,
known_dimensions=known_dims_str,
)
response = await call_llm(model, prompt)
decision = parse_resolution_decision(response)
if not decision:
await _mark_resolved(pool, item["id"], "error", {"raw_response": response[:500]})
return "error"
outcome = decision.decision
if outcome == "decompose" and collision_type == "isa_isa":
await _apply_decompose(pool, item, decision, concept_token, existing_parent, incoming_parent)
elif outcome == "update" and collision_type == "ispart_ispart":
await _apply_update(pool, item)
elif outcome == "reclassify":
await _apply_reclassify(pool, item, decision)
else:
outcome = "dismiss"
await _mark_resolved(
pool, item["id"], "resolved",
{"decision": outcome, "reasoning": decision.reasoning},
)
return outcome
async def _apply_decompose(pool, item, decision, concept_token, existing_parent, incoming_parent):
"""Create two new dimensions, migrate existing fact, insert incoming fact."""
existing_dim_token = decision.existing_dimension or "type-a"
new_dim_token = decision.new_dimension or "type-b"
existing_dim_row = await get_or_create_soas(pool, existing_dim_token)
new_dim_row = await get_or_create_soas(pool, new_dim_token)
# Create root nodes for new dimensions
async with pool.acquire() as conn:
for dim_row in [existing_dim_row, new_dim_row]:
await conn.execute(
"INSERT INTO urd (id, parent_id, dim_id, is_isa, confidence, source) "
"VALUES ($1, $1, $1, true, 1.0, 'festinger') ON CONFLICT DO NOTHING",
dim_row.id,
)
# Migrate existing edge to new existing_dimension
async with pool.acquire() as conn:
await conn.execute(
"DELETE FROM urd WHERE id=$1 AND dim_id=$2",
item["concept_id"], item["dim_id"],
)
existing_parent_row = await get_or_create_soas(pool, cache.soas_by_id.get(item["existing_parent_id"], existing_parent))
incoming_parent_row = await get_or_create_soas(pool, cache.soas_by_id.get(item["incoming_parent_id"], incoming_parent))
for parent_row, dim_row in [
(existing_parent_row, existing_dim_row),
(incoming_parent_row, new_dim_row),
]:
req = InsertRequest(
concept_id=item["concept_id"],
parent_id=parent_row.id,
dim_id=dim_row.id,
is_isa=True,
confidence=0.9,
source="festinger",
)
await insert_urd_edge(pool, req)
log.info("decompose applied: %s → [%s, %s]", item["concept_id"], existing_dim_token, new_dim_token)
async def _apply_update(pool, item):
"""Replace old ISPART edge with incoming fact."""
async with pool.acquire() as conn:
await conn.execute(
"DELETE FROM urd WHERE id=$1 AND dim_id=$2",
item["concept_id"], item["dim_id"],
)
req = InsertRequest(
concept_id=item["concept_id"],
parent_id=item["incoming_parent_id"],
dim_id=item["dim_id"],
is_isa=False,
confidence=0.85,
source="festinger",
)
await insert_urd_edge(pool, req)
log.info("update applied: concept=%d dim=%d → parent=%d",
item["concept_id"], item["dim_id"], item["incoming_parent_id"])
async def _apply_reclassify(pool, item, decision):
"""Insert incoming fact in the corrected dimension."""
correct_dim_token = decision.correct_dimension or "membership"
dim_row = await get_or_create_soas(pool, correct_dim_token)
req = InsertRequest(
concept_id=item["concept_id"],
parent_id=item["incoming_parent_id"],
dim_id=dim_row.id,
is_isa=False,
confidence=0.8,
source="festinger",
)
await insert_urd_edge(pool, req)
log.info("reclassify applied: concept=%d → dim=%s", item["concept_id"], correct_dim_token)
async def _mark_resolved(pool, queue_id: int, status: str, resolution: dict) -> None:
async with pool.acquire() as conn:
await conn.execute(
"UPDATE resolution_queue SET status=$1, resolution=$2, resolved_at=now() WHERE id=$3",
status, json.dumps(resolution), queue_id,
)
def last_run_timestamp() -> str | None:
if _last_run is None:
return None
return _last_run.isoformat()
@@ -0,0 +1,304 @@
"""
Test scenario seeder for Festinger.
Seeds the database and in-memory cache with pre-defined world-model states,
then exposes a trigger endpoint so gutasktool (or a test script) can fire the
conflicting fact and observe the collision + resolution pipeline.
Scenarios
---------
A misclassification
Seed: michigan ISPART usa in dim:usa (parent_id = dim_id — coarse/degenerate edge)
Trigger: gutask iknowthat 'michigan -isa state in context of usa'
Expect: misclassification collision → resolution moves ISA fact to dim:type
B ISA + ISA decomposition
Seed: gnommoweb ISA container in dim:type
Trigger: gutask iknowthat 'gnommoweb -isa repo in context of type'
Expect: isa_isa collision → nightly job decomposes into artifact-type + deployment-type
C ISPART + ISPART contradiction
Seed: dobby ISPART docker_host_1 in dim:runs-on
Trigger: gutask iknowthat 'dobby -ispart docker_host_2 in context of runs-on'
Expect: ispart_ispart collision → nightly job arbitrates (update or dismiss)
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
import asyncpg
from . import cache
from .cache import SoasRow, UrdEdge
from .db import get_or_create_soas
log = logging.getLogger("festinger.test")
# ---------------------------------------------------------------------------
# Scenario definitions
# ---------------------------------------------------------------------------
@dataclass
class SeedEdge:
concept: str
parent: str
dimension: str
is_isa: bool
confidence: float = 0.9
source: str = "test"
note: str = ""
@dataclass
class Scenario:
id: str
name: str
description: str
seed_edges: list[SeedEdge]
trigger_fact: str # the fact to POST to /iknowthat to cause the collision
expected_collision: str # 'isa_isa' | 'ispart_ispart' | 'misclassification'
expected_resolution: str # 'decompose' | 'update' | 'dismiss' | 'reclassify'
notes: str = ""
SCENARIOS: dict[str, Scenario] = {
"A": Scenario(
id="A",
name="misclassification — michigan/state/usa",
description=(
"The world model holds a coarse, degenerate fact: michigan ISPART usa "
"in the 'usa' dimension (parent_id = dim_id). This is an early, "
"undifferentiated encoding — the location context IS the dimension. "
"A new fact 'michigan is a state in context of usa' is ISA in the same "
"dimension, triggering a misclassification collision. Resolution should "
"suggest moving the ISA fact to the 'type' dimension."
),
seed_edges=[
SeedEdge(
concept="michigan",
parent="usa",
dimension="usa", # parent_id = dim_id — the degenerate pattern
is_isa=False, # ISPART
confidence=0.85,
note="coarse pre-knowledge: michigan is part of usa, dim=usa (undifferentiated)",
),
],
trigger_fact="michigan -isa state in context of usa",
expected_collision="misclassification",
expected_resolution="reclassify",
notes=(
"The degenerate parent_id=dim_id pattern arises naturally when the world model "
"first hears 'michigan is in usa' before any geography dimension exists. "
"Resolution should split: ISPART goes to 'geography', ISA goes to 'type'."
),
),
"B": Scenario(
id="B",
name="ISA+ISA decomposition — gnommoweb/container/repo",
description=(
"The world model holds gnommoweb ISA container in dim:type. "
"Both 'container' (deployment artifact) and 'repo' (software artifact) are "
"simultaneously true, but the 'type' dimension is too coarse to hold both. "
"Resolution should decompose into 'deployment-type' and 'artifact-type'."
),
seed_edges=[
SeedEdge(
concept="gnommoweb",
parent="container",
dimension="type",
is_isa=True,
confidence=0.9,
note="gnommoweb runs as a Docker container",
),
],
trigger_fact="gnommoweb -isa repo in context of type",
expected_collision="isa_isa",
expected_resolution="decompose",
notes="Classic dimension-too-coarse case from PROJECT.md test case B.",
),
"C": Scenario(
id="C",
name="ISPART+ISPART contradiction — dobby/host migration",
description=(
"The world model holds dobby ISPART docker_host_1 in dim:runs-on. "
"A new fact places dobby on docker_host_2, contradicting the existing "
"runs-on edge. One host is correct; the nightly job must arbitrate."
),
seed_edges=[
SeedEdge(
concept="dobby",
parent="docker_host_1",
dimension="runs-on",
is_isa=False,
confidence=0.9,
note="dobby agent originally deployed on docker_host_1",
),
],
trigger_fact="dobby -ispart docker_host_2 in context of runs-on",
expected_collision="ispart_ispart",
expected_resolution="update",
notes="Simulates a container migration where the new host fact should win.",
),
}
# ---------------------------------------------------------------------------
# Seeder
# ---------------------------------------------------------------------------
async def seed_scenario(pool: asyncpg.Pool, scenario_id: str) -> dict[str, Any]:
"""
Insert SOAS + URD entries for a named scenario.
Returns a summary of what was created, including the token→id mapping
and the trigger fact to POST to /iknowthat.
"""
sc = SCENARIOS.get(scenario_id.upper())
if sc is None:
return {"error": f"unknown scenario: {scenario_id!r}. Valid: {list(SCENARIOS)}"}
created_soas: list[dict] = []
created_edges: list[dict] = []
collisions: list[dict] = []
for edge in sc.seed_edges:
concept_row = await get_or_create_soas(pool, edge.concept)
parent_row = await get_or_create_soas(pool, edge.parent)
dim_row = await get_or_create_soas(pool, edge.dimension)
for row in [concept_row, parent_row, dim_row]:
created_soas.append({"token": row.token, "id": row.id})
# Manually set encounter_count + saliency above read threshold so
# the concept appears in recollections during the test prompt.
async with pool.acquire() as conn:
await conn.execute(
"UPDATE soas SET encounter_count=50, saliency=1.5 WHERE id=$1",
concept_row.id,
)
if concept_row.token in cache.soas_by_token:
cache.soas_by_token[concept_row.token].encounter_count = 50
cache.soas_by_token[concept_row.token].saliency = 1.5
# Insert URD edge — direct Postgres write, bypassing collision detection,
# because this is deliberate seed state (not a user-facing write path).
key = (concept_row.id, dim_row.id)
existing = cache.urd_by_concept_dim.get(key)
if existing is not None:
collisions.append({
"concept": edge.concept,
"dimension": edge.dimension,
"message": "edge already present in cache — skipped",
})
continue
try:
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO urd (id, parent_id, dim_id, is_isa, confidence, source)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING
""",
concept_row.id, parent_row.id, dim_row.id,
edge.is_isa, edge.confidence, edge.source,
)
except Exception as e:
collisions.append({"concept": edge.concept, "error": str(e)})
continue
# Update in-memory cache
urd_edge = UrdEdge(
concept_id=concept_row.id,
parent_id=parent_row.id,
dim_id=dim_row.id,
is_isa=edge.is_isa,
confidence=edge.confidence,
source=edge.source,
parent_token=parent_row.token,
dim_token=dim_row.token,
)
cache.urd_by_concept.setdefault(concept_row.id, []).append(urd_edge)
cache.urd_by_concept_dim[key] = urd_edge
created_edges.append({
"concept": edge.concept,
"concept_id": concept_row.id,
"parent": edge.parent,
"parent_id": parent_row.id,
"dimension": edge.dimension,
"dim_id": dim_row.id,
"is_isa": edge.is_isa,
"parent_equals_dim": parent_row.id == dim_row.id,
"note": edge.note,
})
return {
"scenario": sc.id,
"name": sc.name,
"description": sc.description,
"soas_entries": created_soas,
"urd_edges": created_edges,
"skipped_collisions": collisions,
"next_step": {
"action": "POST /iknowthat",
"body": {"fact": sc.trigger_fact},
"gutask_command": f"gutask iknowthat '{sc.trigger_fact}'",
"expected_collision": sc.expected_collision,
"expected_resolution": sc.expected_resolution,
},
"notes": sc.notes,
}
async def reset_scenario(pool: asyncpg.Pool, scenario_id: str) -> dict[str, Any]:
"""
Remove the URD edges and SOAS tokens introduced by a scenario seed.
Clears the corresponding cache entries and resolution queue rows.
Use before re-running a scenario to get a clean slate.
"""
sc = SCENARIOS.get(scenario_id.upper())
if sc is None:
return {"error": f"unknown scenario: {scenario_id!r}"}
removed_urd = 0
removed_rq = 0
concept_tokens = {e.concept for e in sc.seed_edges}
concept_ids = [
cache.soas_by_token[t].id
for t in concept_tokens
if t in cache.soas_by_token
]
async with pool.acquire() as conn:
for cid in concept_ids:
# Remove resolution queue entries
r = await conn.execute(
"DELETE FROM resolution_queue WHERE concept_id=$1", cid
)
removed_rq += int(r.split()[-1])
# Remove URD edges where this concept is the subject
r = await conn.execute("DELETE FROM urd WHERE id=$1", cid)
removed_urd += int(r.split()[-1])
# Clear from in-memory cache
cache.urd_by_concept.pop(cid, None)
for key in [k for k in cache.urd_by_concept_dim if k[0] == cid]:
del cache.urd_by_concept_dim[key]
cache.pending_conflicts.discard(cid)
return {
"scenario": scenario_id.upper(),
"removed_urd_edges": removed_urd,
"removed_resolution_queue_rows": removed_rq,
"status": "reset complete",
}
+117
View File
@@ -0,0 +1,117 @@
"""
Tokeniser — compound token rule + punctuation stripping.
Rules:
1. Split on whitespace.
2. Consecutive tokens each starting with a capital letter are merged into a
single underscore-joined lowercase token (compound token rule).
A lowercase or non-alpha token breaks the run.
3. Strip leading/trailing punctuation from each token.
4. Lowercase all tokens.
5. Discard tokens shorter than 5 characters (unless they surfaced as part of
a matched relationship cue — cue scanner handles that separately).
"""
from __future__ import annotations
import re
import unicodedata
# Characters that are stripped from token edges
_PUNCT_RE = re.compile(r"^[^\w]+|[^\w]+$")
def _strip_punct(s: str) -> str:
return _PUNCT_RE.sub("", s)
def tokenize(text: str) -> list[str]:
"""
Return a deduplicated, ordered list of canonical tokens extracted from
*text*, applying the compound token rule.
"""
raw_words = text.split()
tokens: list[str] = []
run: list[str] = []
def flush_run() -> None:
if not run:
return
if len(run) > 1:
tokens.append("_".join(w.lower() for w in run))
else:
tokens.append(run[0].lower())
run.clear()
for word in raw_words:
stripped = _strip_punct(word)
# Trailing punctuation (sentence boundary) ends a compound run.
# Commas/colons/periods after a word mean the next word starts fresh.
has_trailing_punct = bool(word) and not word[-1].isalnum() and word[-1] != "_"
if not stripped:
flush_run()
continue
# A word starts a capital run if its first alphabetic character is uppercase
first_alpha = next((c for c in stripped if c.isalpha()), None)
if first_alpha and first_alpha.isupper():
run.append(stripped)
if has_trailing_punct:
flush_run() # e.g. "FastAPI." ends the run immediately
else:
flush_run()
t = stripped.lower()
if t:
tokens.append(t)
flush_run()
# Filter: keep tokens >= 5 chars, deduplicate preserving order
seen: set[str] = set()
result: list[str] = []
for t in tokens:
if len(t) >= 5 and t not in seen:
seen.add(t)
result.append(t)
return result
def tokenize_all(text: str) -> list[str]:
"""
Like tokenize() but returns ALL tokens (including short ones).
Used by the cue scanner which needs short words for pattern matching.
"""
raw_words = text.split()
tokens: list[str] = []
run: list[str] = []
def flush_run() -> None:
if not run:
return
if len(run) > 1:
tokens.append("_".join(w.lower() for w in run))
else:
tokens.append(run[0].lower())
run.clear()
for word in raw_words:
stripped = _strip_punct(word)
has_trailing_punct = bool(word) and not word[-1].isalnum() and word[-1] != "_"
if not stripped:
flush_run()
continue
first_alpha = next((c for c in stripped if c.isalpha()), None)
if first_alpha and first_alpha.isupper():
run.append(stripped)
if has_trailing_punct:
flush_run()
else:
flush_run()
t = stripped.lower()
if t:
tokens.append(t)
flush_run()
return tokens
+157
View File
@@ -0,0 +1,157 @@
"""
URD insert pipeline — collision detection and write-through.
Insert flow:
key = (concept_id, dim_id)
if key in urd_by_concept_dim → collision detected in-memory
→ classify (is_isa flags), route to resolution queue
else
→ INSERT into Postgres URD
→ on success: update urd_by_concept + urd_by_concept_dim
→ on UniqueViolation (race): reload row, route to resolution queue
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
import asyncpg
from . import cache
from .cache import UrdEdge
log = logging.getLogger("festinger.urd_writer")
@dataclass
class InsertRequest:
concept_id: int
parent_id: int
dim_id: int
is_isa: bool
confidence: float
source: str
@dataclass
class CollisionInfo:
concept_id: int
existing_parent_id: int
incoming_parent_id: int
dim_id: int
existing_is_isa: bool
incoming_is_isa: bool
@property
def collision_type(self) -> str:
if self.existing_is_isa and self.incoming_is_isa:
return "isa_isa"
if not self.existing_is_isa and not self.incoming_is_isa:
return "ispart_ispart"
return "misclassification"
async def insert_urd_edge(
pool: asyncpg.Pool,
req: InsertRequest,
priority: bool = False,
) -> Optional[CollisionInfo]:
"""
Attempt to insert a URD edge.
Returns CollisionInfo if there was a collision, None on success.
"""
key = (req.concept_id, req.dim_id)
# Fast-path collision detection — in-memory
existing = cache.urd_by_concept_dim.get(key)
if existing is not None:
collision = CollisionInfo(
concept_id=req.concept_id,
existing_parent_id=existing.parent_id,
incoming_parent_id=req.parent_id,
dim_id=req.dim_id,
existing_is_isa=existing.is_isa,
incoming_is_isa=req.is_isa,
)
await _queue_collision(pool, collision, priority)
return collision
# Attempt Postgres insert
try:
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO urd (id, parent_id, dim_id, is_isa, confidence, source)
VALUES ($1, $2, $3, $4, $5, $6)
""",
req.concept_id, req.parent_id, req.dim_id,
req.is_isa, req.confidence, req.source,
)
except asyncpg.UniqueViolationError:
# Race condition — another process inserted concurrently
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT parent_id, is_isa FROM urd WHERE id=$1 AND dim_id=$2",
req.concept_id, req.dim_id,
)
if row:
collision = CollisionInfo(
concept_id=req.concept_id,
existing_parent_id=row["parent_id"],
incoming_parent_id=req.parent_id,
dim_id=req.dim_id,
existing_is_isa=row["is_isa"],
incoming_is_isa=req.is_isa,
)
await _queue_collision(pool, collision, priority)
return collision
return None
# Success — update in-memory cache
parent_token = cache.soas_by_id.get(req.parent_id, str(req.parent_id))
dim_token = cache.soas_by_id.get(req.dim_id, str(req.dim_id))
edge = UrdEdge(
concept_id=req.concept_id,
parent_id=req.parent_id,
dim_id=req.dim_id,
is_isa=req.is_isa,
confidence=req.confidence,
source=req.source,
parent_token=parent_token,
dim_token=dim_token,
)
cache.urd_by_concept.setdefault(req.concept_id, []).append(edge)
cache.urd_by_concept_dim[key] = edge
log.info(
"urd insert concept=%d parent=%d dim=%d is_isa=%s source=%s",
req.concept_id, req.parent_id, req.dim_id, req.is_isa, req.source,
)
return None
async def _queue_collision(
pool: asyncpg.Pool,
col: CollisionInfo,
priority: bool,
) -> None:
cache.pending_conflicts.add(col.concept_id)
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO resolution_queue
(concept_id, existing_parent_id, incoming_parent_id, dim_id,
collision_type, priority)
VALUES ($1, $2, $3, $4, $5, $6)
""",
col.concept_id,
col.existing_parent_id,
col.incoming_parent_id,
col.dim_id,
col.collision_type,
priority,
)
log.warning(
"collision queued concept=%d type=%s priority=%s",
col.concept_id, col.collision_type, priority,
)
+125
View File
@@ -0,0 +1,125 @@
"""
WordNet importer — loads Princeton WordNet 3.x index files into SOAS.
Reads index.noun, index.verb, index.adj, index.adv from the wordnet/ directory.
Each non-header line's first field is the lemma (already lowercase, underscores
for compound words — matches our compound token convention exactly).
All tokens are inserted with saliency=0, novelty=0 (common English baseline).
Insert is idempotent: ON CONFLICT DO NOTHING.
Citation:
Princeton University "About WordNet." WordNet. Princeton University. 2010.
https://wordnet.princeton.edu/
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import AsyncIterator
import asyncpg
from . import cache
from .cache import SoasRow
log = logging.getLogger("festinger.wordnet")
WORDNET_DIR = Path(__file__).parent.parent / "wordnet"
INDEX_FILES = ["index.noun", "index.verb", "index.adj", "index.adv"]
BATCH_SIZE = 2000
CITATION = (
'Princeton University "About WordNet." WordNet. '
"Princeton University. 2010. https://wordnet.princeton.edu/"
)
def _parse_index_file(path: Path) -> list[str]:
"""
Extract lemma tokens from a WordNet index file.
Header lines start with a space or are blank — skip them.
Data line format: lemma pos synset_cnt p_cnt ...
Lemmas are already lowercase; underscores join compound words.
"""
tokens: list[str] = []
try:
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
if not line or line[0] in (" ", "\t", "\n"):
continue
lemma = line.split()[0]
# Skip purely numeric tokens and single chars
if lemma and not lemma.isdigit() and len(lemma) > 1:
tokens.append(lemma)
except FileNotFoundError:
log.warning("wordnet file not found: %s", path)
return tokens
def collect_all_lemmas() -> list[str]:
"""Parse all four index files and return a deduplicated list of lemmas."""
seen: set[str] = set()
result: list[str] = []
for fname in INDEX_FILES:
for token in _parse_index_file(WORDNET_DIR / fname):
if token not in seen:
seen.add(token)
result.append(token)
return result
async def import_wordnet(pool: asyncpg.Pool) -> dict:
"""
Bulk-load all WordNet lemmas into SOAS (saliency=0, novelty=0).
Updates the in-memory cache with any newly inserted tokens.
Returns a summary dict.
"""
if not WORDNET_DIR.exists():
return {"error": f"wordnet directory not found at {WORDNET_DIR}"}
lemmas = collect_all_lemmas()
total = len(lemmas)
log.info("wordnet: %d lemmas collected, beginning import …", total)
inserted = 0
skipped = 0
async with pool.acquire() as conn:
# Process in batches to avoid huge transactions
for i in range(0, total, BATCH_SIZE):
batch = lemmas[i : i + BATCH_SIZE]
# INSERT … ON CONFLICT DO NOTHING, then RETURNING to know what was new
rows = await conn.fetch(
"""
INSERT INTO soas (token, saliency, novelty)
SELECT unnest($1::text[]), 0.0, 0.0
ON CONFLICT (token) DO NOTHING
RETURNING id, token
""",
batch,
)
for r in rows:
soas_row = SoasRow(id=r["id"], token=r["token"])
cache.soas_by_token[r["token"]] = soas_row
cache.soas_by_id[r["id"]] = r["token"]
inserted += 1
skipped += len(batch) - len(rows)
if (i // BATCH_SIZE) % 10 == 0:
log.info("wordnet import: %d / %d", i + len(batch), total)
log.info(
"wordnet import complete: %d inserted, %d already present, %d total",
inserted, skipped, total,
)
return {
"status": "ok",
"total_lemmas": total,
"inserted": inserted,
"already_present": skipped,
"citation": CITATION,
}
+171
View File
@@ -0,0 +1,171 @@
"""
Async write queue — background processing of saliency-triggered and
cue-triggered write requests.
Cloud LLM calls and URD inserts never block the proxy response path.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import Optional
import asyncpg
from .cache import SoasRow
from .cue_scanner import CueTriple
from .db import get_or_create_soas, get_config
from .llm_client import (
ModelConfig, get_model_config, call_llm,
WRITE_PROMPT_TEMPLATE, parse_llm_triples,
)
from .urd_writer import InsertRequest, insert_urd_edge
from . import cache
log = logging.getLogger("festinger.write_queue")
@dataclass
class WriteRequest:
"""A concept that crossed the write threshold — needs LLM-assisted classification."""
concept_token: str
trigger: str = "saliency" # 'saliency' | 'cue'
@dataclass
class CueWriteRequest:
"""A directly extracted CueTriple — bypasses LLM, goes straight to URD insert."""
triple: CueTriple
_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
_running: bool = False
async def enqueue_concept(token: str) -> None:
try:
_queue.put_nowait(WriteRequest(concept_token=token))
except asyncio.QueueFull:
log.warning("write queue full — dropping concept: %s", token)
async def enqueue_cue(triple: CueTriple) -> None:
try:
_queue.put_nowait(CueWriteRequest(triple=triple))
except asyncio.QueueFull:
log.warning("write queue full — dropping cue: %s", triple)
async def start_worker(pool: asyncpg.Pool) -> None:
"""Launch background worker. Call once at startup."""
global _running
_running = True
asyncio.create_task(_worker(pool))
log.info("write queue worker started")
async def _worker(pool: asyncpg.Pool) -> None:
while _running:
try:
item = await asyncio.wait_for(_queue.get(), timeout=5.0)
except asyncio.TimeoutError:
continue
try:
if isinstance(item, CueWriteRequest):
await _process_cue(pool, item.triple)
elif isinstance(item, WriteRequest):
await _process_concept(pool, item.concept_token)
except Exception as e:
log.exception("write queue worker error: %s", e)
finally:
_queue.task_done()
async def stop_worker() -> None:
global _running
_running = False
# ---------------------------------------------------------------------------
# Processing
# ---------------------------------------------------------------------------
async def _process_cue(pool: asyncpg.Pool, triple: CueTriple) -> None:
"""Insert a cue-extracted triple directly into URD."""
subj_row = await get_or_create_soas(pool, triple.subject)
parent_row = await get_or_create_soas(pool, triple.parent)
dim_row = await get_or_create_soas(pool, triple.dimension)
req = InsertRequest(
concept_id=subj_row.id,
parent_id=parent_row.id,
dim_id=dim_row.id,
is_isa=triple.is_isa,
confidence=triple.confidence,
source="inferred",
)
collision = await insert_urd_edge(pool, req)
if collision:
log.info("cue triple collision: %s", collision)
async def _process_concept(pool: asyncpg.Pool, concept_token: str) -> None:
"""Call cloud LLM to classify the concept, then insert all returned triples."""
write_model_id = await get_config(pool, "write_model_id")
if not write_model_id:
log.debug("no write_model_id configured — skipping LLM write for %s", concept_token)
return
model = await get_model_config(pool, write_model_id)
if not model:
log.warning("write_model_id=%s not found in models table", write_model_id)
return
known_dims = list(cache.soas_by_token.keys())
# Keep only seed dimensions + short list for prompt brevity
seed_dims = ["type", "membership", "runs-on", "tech", "owned-by", "geography"]
dimensions_str = ", ".join(seed_dims)
prompt = WRITE_PROMPT_TEMPLATE.format(
concept=concept_token,
dimensions=dimensions_str,
)
try:
response = await call_llm(model, prompt)
except Exception as e:
log.warning("LLM call failed for concept %s: %s", concept_token, e)
return
triples = parse_llm_triples(response)
if not triples:
log.info("LLM returned no triples for concept: %s", concept_token)
return
subj_row = await get_or_create_soas(pool, concept_token)
for t in triples:
if not t.parent or not t.dimension:
continue
parent_row = await get_or_create_soas(pool, t.parent)
dim_row = await get_or_create_soas(pool, t.dimension)
req = InsertRequest(
concept_id=subj_row.id,
parent_id=parent_row.id,
dim_id=dim_row.id,
is_isa=t.is_isa,
confidence=t.confidence,
source="cloud_llm",
)
await insert_urd_edge(pool, req)
# Mark concept as confirmed — set novelty=1.0
async with pool.acquire() as conn:
await conn.execute(
"UPDATE soas SET novelty = 1.0 WHERE id = $1", subj_row.id
)
if concept_token in cache.soas_by_token:
cache.soas_by_token[concept_token].novelty = 1.0
+6
View File
@@ -0,0 +1,6 @@
name: festinger
title: "Festinger"
description: "Ollama-compatible inference middleware for Agent0. Detects and breaks reasoning loops. Maintains a structured, contradiction-resistant world model (Recollections) that enriches every prompt spontaneously. Named after Leon Festinger, who introduced the theory of cognitive dissonance."
version: 0.1.0
type: inference_proxy
settings_sections: []
+292
View File
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
Loop Detector Proxy — Agent0 Plugin
An Ollama-compatible HTTP proxy that intercepts LLM completions, detects
repeated outputs, and applies configurable mitigations before returning the
response to the caller (agent-zero).
Architecture:
agent-zero → loop-detector:11434 → ollama:11434 (host)
POC limitations:
- Forces stream=false on all forwarded requests to simplify response
buffering. Streaming responses are re-emitted as single JSON objects,
which agent-zero handles correctly for task execution.
- State is in-memory; restarting the proxy clears all session history.
"""
import hashlib
import json
import logging
import time
from collections import defaultdict, deque
from pathlib import Path
from typing import Any
import httpx
import yaml
from fastapi import FastAPI, Request, Response
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
)
log = logging.getLogger("loop_detector")
CONFIG_PATH = Path(__file__).parent / "config.yaml"
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
def load_config() -> dict:
with open(CONFIG_PATH) as f:
return yaml.safe_load(f)
# ---------------------------------------------------------------------------
# Session state
# ---------------------------------------------------------------------------
# Key: (model, session_fingerprint) → deque of response hashes
_history: dict[tuple, deque] = defaultdict(lambda: deque(maxlen=20))
# Key: same tuple → current consecutive-repeat count
_consecutive: dict[tuple, int] = defaultdict(int)
_last_hash: dict[tuple, str] = {}
def _session_key(model: str, messages: list[dict]) -> tuple[str, str]:
"""
Derive a stable session identifier from the model name and the content
of the first system message (which is unique per agent profile).
Falls back to a hash of all non-assistant messages if there is no system
message.
"""
system_msgs = [m for m in messages if m.get("role") == "system"]
if system_msgs:
fingerprint = hashlib.sha256(
system_msgs[0].get("content", "")[:512].encode()
).hexdigest()[:16]
else:
non_assistant = [m for m in messages if m.get("role") != "assistant"]
blob = json.dumps(non_assistant, sort_keys=True)
fingerprint = hashlib.sha256(blob.encode()).hexdigest()[:16]
return (model, fingerprint)
def _hash_response(text: str) -> str:
return hashlib.sha256(text.strip().encode()).hexdigest()
def record_and_check(session: tuple, text: str, min_length: int) -> int:
"""
Record a completion and return the current consecutive-repeat count.
Returns 1 (no loop) if the text is below min_length.
"""
if len(text.strip()) < min_length:
return 1
h = _hash_response(text)
history = _history[session]
if _last_hash.get(session) == h:
_consecutive[session] += 1
else:
_consecutive[session] = 1
_last_hash[session] = h
history.append(h)
return _consecutive[session]
# ---------------------------------------------------------------------------
# Mitigations
# ---------------------------------------------------------------------------
def apply_mitigations(
request_body: dict,
count: int,
config: dict,
) -> tuple[dict, str | None]:
"""
Walk the configured mitigation list in order.
Returns (modified_request_body, override_response_text_or_None).
An override_response means "return this to the caller, skip the LLM".
"""
mitigations = config.get("mitigations", [])
override: str | None = None
for m in mitigations:
if not m.get("enabled", True):
continue
if count < m.get("trigger_count", 2):
continue
strategy = m["strategy"]
if strategy == "temperature_boost":
opts = request_body.setdefault("options", {})
current = opts.get("temperature", 0.7)
boosted = min(current + m.get("boost_amount", 0.35), m.get("max_temperature", 1.4))
opts["temperature"] = boosted
log.warning("mitigation=temperature_boost %.2f%.2f (count=%d)", current, boosted, count)
elif strategy == "forbidden_action":
msg = m.get("injection_message", "STOP. Try something completely different.").format(count=count)
request_body.setdefault("messages", []).append({"role": "user", "content": msg})
log.warning("mitigation=forbidden_action injected (count=%d)", count)
elif strategy == "history_truncation":
messages = request_body.get("messages", [])
truncate = m.get("truncate_turns", 6)
system_msgs = [m_ for m_ in messages if m_.get("role") == "system"]
non_system = [m_ for m_ in messages if m_.get("role") != "system"]
# Keep at least the most recent exchange after truncation
trimmed = non_system[:-truncate] if len(non_system) > truncate else non_system[-2:]
request_body["messages"] = system_msgs + trimmed
log.warning("mitigation=history_truncation dropped %d turns (count=%d)", truncate, count)
elif strategy == "circuit_breaker":
override = m.get("response_message", "[LOOP DETECTOR] Halted due to repeated responses.")
log.error("mitigation=circuit_breaker TRIGGERED (count=%d)", count)
break # nothing further makes sense
return request_body, override
# ---------------------------------------------------------------------------
# Ollama forwarding
# ---------------------------------------------------------------------------
async def call_ollama(path: str, body: dict, upstream: str) -> tuple[str, dict]:
"""
Forward a request to upstream Ollama (stream=False) and return
(assistant_text, raw_response_dict).
"""
body = dict(body)
body["stream"] = False
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{upstream}{path}", json=body)
r.raise_for_status()
data = r.json()
if path == "/api/chat":
text = data.get("message", {}).get("content", "")
else:
text = data.get("response", "")
return text, data
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(title="Loop Detector Proxy")
@app.post("/api/chat")
async def chat(request: Request) -> Response:
config = load_config()
body = await request.json()
model = body.get("model", "unknown")
upstream = config["upstream_ollama"]
min_len = config["detection"]["min_length"]
text, raw = await call_ollama("/api/chat", body, upstream)
session = _session_key(model, body.get("messages", []))
count = record_and_check(session, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, session[1], count)
body, override = apply_mitigations(body, count, config)
if override is not None:
raw["message"] = {"role": "assistant", "content": override}
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
# Retry with mitigations applied
text, raw = await call_ollama("/api/chat", body, upstream)
record_and_check(session, text, min_len)
raw["message"] = {"role": "assistant", "content": text}
return Response(content=json.dumps(raw), media_type="application/json")
@app.post("/api/generate")
async def generate(request: Request) -> Response:
config = load_config()
body = await request.json()
model = body.get("model", "unknown")
upstream = config["upstream_ollama"]
min_len = config["detection"]["min_length"]
# /api/generate has no messages list; use prompt as fingerprint
messages = [{"role": "user", "content": body.get("prompt", "")}]
session = _session_key(model, messages)
text, raw = await call_ollama("/api/generate", body, upstream)
count = record_and_check(session, text, min_len)
if count >= 2:
log.warning("loop detected model=%s session=%s count=%d", model, session[1], count)
body, override = apply_mitigations(body, count, config)
if override is not None:
raw["response"] = override
raw["loop_detected"] = True
return Response(content=json.dumps(raw), media_type="application/json")
text, raw = await call_ollama("/api/generate", body, upstream)
record_and_check(session, text, min_len)
raw["response"] = text
return Response(content=json.dumps(raw), media_type="application/json")
@app.get("/health")
async def health() -> dict:
cfg = load_config()
return {
"status": "ok",
"upstream": cfg["upstream_ollama"],
"active_sessions": len(_history),
"timestamp": int(time.time()),
}
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"])
async def passthrough(path: str, request: Request) -> Response:
"""Forward anything we don't handle (model listing, embeddings, etc.) straight through."""
config = load_config()
upstream = config["upstream_ollama"]
body = await request.body()
headers = {k: v for k, v in request.headers.items() if k.lower() != "host"}
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.request(
request.method,
f"{upstream}/{path}",
content=body,
headers=headers,
)
return Response(
content=r.content,
status_code=r.status_code,
media_type=r.headers.get("content-type"),
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
cfg = load_config()
uvicorn.run(app, host="0.0.0.0", port=cfg["proxy_port"], log_level="info")
+6
View File
@@ -0,0 +1,6 @@
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
+10
View File
@@ -0,0 +1,10 @@
fastapi==0.128.8
uvicorn==0.39.0
httpx==0.28.1
pyyaml==6.0.3
asyncpg==0.31.0
anthropic==0.96.0
openai==2.32.0
apscheduler==3.11.2
pytest==8.4.0
pytest-asyncio==0.26.0
View File
+53
View File
@@ -0,0 +1,53 @@
"""
Test helpers — populate the in-memory cache directly without touching Postgres.
All collision detection, recollection rendering, and queue routing logic operates
entirely on the module-level dicts in festinger.cache, so unit tests can inject
state there directly and assert outcomes without a live database.
"""
from __future__ import annotations
from festinger import cache
from festinger.cache import SoasRow, UrdEdge
def reset_cache() -> None:
"""Clear all in-memory state. Call at the start of each test."""
cache.soas_by_token.clear()
cache.soas_by_id.clear()
cache.urd_by_concept.clear()
cache.urd_by_concept_dim.clear()
cache.pending_conflicts.clear()
cache._encounter_deltas.clear()
def add_soas(id: int, token: str, saliency: float = 0.0, novelty: float = 0.0) -> SoasRow:
row = SoasRow(id=id, token=token, saliency=saliency, novelty=novelty)
cache.soas_by_token[token] = row
cache.soas_by_id[id] = token
return row
def add_urd(
concept_id: int,
parent_id: int,
dim_id: int,
is_isa: bool,
confidence: float = 0.9,
source: str = "test",
) -> UrdEdge:
parent_token = cache.soas_by_id.get(parent_id, str(parent_id))
dim_token = cache.soas_by_id.get(dim_id, str(dim_id))
edge = UrdEdge(
concept_id=concept_id,
parent_id=parent_id,
dim_id=dim_id,
is_isa=is_isa,
confidence=confidence,
source=source,
parent_token=parent_token,
dim_token=dim_token,
)
cache.urd_by_concept.setdefault(concept_id, []).append(edge)
cache.urd_by_concept_dim[(concept_id, dim_id)] = edge
return edge
+284
View File
@@ -0,0 +1,284 @@
"""
Collision detection tests — exercises the in-memory URD insert pipeline.
All tests inject state directly into the cache module dicts and call
insert_urd_edge() with a mock asyncpg pool that records calls but never
touches a real database.
"""
from __future__ import annotations
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from festinger import cache
from festinger.urd_writer import InsertRequest, insert_urd_edge, CollisionInfo
from tests.helpers import reset_cache, add_soas, add_urd
# ---------------------------------------------------------------------------
# Mock pool — captures INSERT attempts, never hits Postgres
# ---------------------------------------------------------------------------
def make_mock_pool():
"""Return a mock asyncpg pool where execute() succeeds and never raises."""
conn = AsyncMock()
conn.execute = AsyncMock(return_value="INSERT 0 1")
conn.fetchrow = AsyncMock(return_value=None)
conn.__aenter__ = AsyncMock(return_value=conn)
conn.__aexit__ = AsyncMock(return_value=False)
pool = MagicMock()
pool.acquire = MagicMock(return_value=conn)
return pool, conn
# ---------------------------------------------------------------------------
# Scenario A — misclassification (ISPART existing, ISA incoming, same dim)
#
# Pre-state: michigan ISPART usa in dim:usa (parent_id = dim_id — degenerate edge)
# Trigger: michigan ISA state in dim:usa
# Expected: misclassification collision, no URD modification
# ---------------------------------------------------------------------------
class TestScenarioA:
def setup_method(self):
reset_cache()
# SOAS entries
self.michigan = add_soas(101, "michigan", saliency=1.5)
self.usa = add_soas(102, "usa", saliency=0.8)
self.state = add_soas(103, "state", saliency=0.7)
# Degenerate seed edge: michigan ISPART usa, dim=usa (parent_id = dim_id)
self.existing = add_urd(
concept_id=101, parent_id=102, dim_id=102, # dim_id = parent_id = usa
is_isa=False, confidence=0.85, source="test"
)
def test_degenerate_edge_stored(self):
"""Confirm the seed has parent_id == dim_id."""
edge = cache.urd_by_concept_dim.get((101, 102))
assert edge is not None
assert edge.parent_id == edge.dim_id == 102
@pytest.mark.asyncio
async def test_misclassification_detected(self):
"""Incoming ISA in the same dim as existing ISPART → misclassification."""
pool, conn = make_mock_pool()
# Patch _queue_collision so we can inspect it without hitting Postgres
queued: list[CollisionInfo] = []
async def fake_queue(pool, col, priority):
queued.append(col)
cache.pending_conflicts.add(col.concept_id)
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
req = InsertRequest(
concept_id=101, # michigan
parent_id=103, # state
dim_id=102, # usa (same dim as existing ISPART)
is_isa=True,
confidence=0.85,
source="gutask",
)
collision = await insert_urd_edge(pool, req)
assert collision is not None
assert collision.collision_type == "misclassification"
assert collision.existing_is_isa is False
assert collision.incoming_is_isa is True
@pytest.mark.asyncio
async def test_urd_not_modified_on_collision(self):
"""URD in-memory cache is unchanged after a collision."""
pool, conn = make_mock_pool()
with patch("festinger.urd_writer._queue_collision", new_callable=AsyncMock):
req = InsertRequest(
concept_id=101, parent_id=103, dim_id=102,
is_isa=True, confidence=0.85, source="gutask",
)
await insert_urd_edge(pool, req)
# Original edge still in place
edge = cache.urd_by_concept_dim.get((101, 102))
assert edge is not None
assert edge.parent_id == 102 # usa, unchanged
assert edge.is_isa is False
@pytest.mark.asyncio
async def test_postgres_not_written_on_collision(self):
"""No INSERT to Postgres is attempted when collision is detected in cache."""
pool, conn = make_mock_pool()
with patch("festinger.urd_writer._queue_collision", new_callable=AsyncMock):
req = InsertRequest(
concept_id=101, parent_id=103, dim_id=102,
is_isa=True, confidence=0.85, source="gutask",
)
await insert_urd_edge(pool, req)
conn.execute.assert_not_called()
@pytest.mark.asyncio
async def test_pending_conflicts_marked(self):
"""concept_id is added to pending_conflicts after collision."""
pool, _ = make_mock_pool()
async def fake_queue(pool, col, priority):
cache.pending_conflicts.add(col.concept_id)
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
req = InsertRequest(
concept_id=101, parent_id=103, dim_id=102,
is_isa=True, confidence=0.85, source="gutask",
)
await insert_urd_edge(pool, req)
assert 101 in cache.pending_conflicts
# ---------------------------------------------------------------------------
# Scenario B — ISA + ISA collision (dimension too coarse → decompose)
#
# Pre-state: gnommoweb ISA container in dim:type
# Trigger: gnommoweb ISA repo in dim:type
# Expected: isa_isa collision
# ---------------------------------------------------------------------------
class TestScenarioB:
def setup_method(self):
reset_cache()
self.gnommoweb = add_soas(201, "gnommoweb", saliency=1.5)
self.container = add_soas(202, "container", saliency=0.9)
self.repo = add_soas(203, "repo", saliency=0.8)
self.type_dim = add_soas(1, "type", saliency=0.0)
self.existing = add_urd(
concept_id=201, parent_id=202, dim_id=1,
is_isa=True, confidence=0.9, source="cloud_llm"
)
@pytest.mark.asyncio
async def test_isa_isa_collision_type(self):
pool, _ = make_mock_pool()
queued: list[CollisionInfo] = []
async def fake_queue(pool, col, priority):
queued.append(col)
cache.pending_conflicts.add(col.concept_id)
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
req = InsertRequest(
concept_id=201, parent_id=203, dim_id=1,
is_isa=True, confidence=0.85, source="gutask",
)
collision = await insert_urd_edge(pool, req)
assert collision is not None
assert collision.collision_type == "isa_isa"
assert len(queued) == 1
assert queued[0].existing_parent_id == 202 # container
assert queued[0].incoming_parent_id == 203 # repo
@pytest.mark.asyncio
async def test_existing_edge_unchanged_after_isa_isa(self):
pool, _ = make_mock_pool()
with patch("festinger.urd_writer._queue_collision", new_callable=AsyncMock):
req = InsertRequest(
concept_id=201, parent_id=203, dim_id=1,
is_isa=True, confidence=0.85, source="gutask",
)
await insert_urd_edge(pool, req)
edge = cache.urd_by_concept_dim.get((201, 1))
assert edge.parent_id == 202 # still container
# ---------------------------------------------------------------------------
# Scenario C — ISPART + ISPART contradiction (host migration)
#
# Pre-state: dobby ISPART docker_host_1 in dim:runs-on
# Trigger: dobby ISPART docker_host_2 in dim:runs-on
# Expected: ispart_ispart collision
# ---------------------------------------------------------------------------
class TestScenarioC:
def setup_method(self):
reset_cache()
self.dobby = add_soas(301, "dobby", saliency=1.5)
self.host1 = add_soas(302, "docker_host_1", saliency=0.5)
self.host2 = add_soas(303, "docker_host_2", saliency=0.5)
self.runs_on_dim = add_soas(4, "runs-on", saliency=0.0)
self.existing = add_urd(
concept_id=301, parent_id=302, dim_id=4,
is_isa=False, confidence=0.9, source="cloud_llm"
)
@pytest.mark.asyncio
async def test_ispart_ispart_collision_type(self):
pool, _ = make_mock_pool()
queued: list[CollisionInfo] = []
async def fake_queue(pool, col, priority):
queued.append(col)
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
req = InsertRequest(
concept_id=301, parent_id=303, dim_id=4,
is_isa=False, confidence=1.0, source="gutask",
)
collision = await insert_urd_edge(pool, req)
assert collision is not None
assert collision.collision_type == "ispart_ispart"
assert queued[0].existing_parent_id == 302 # docker_host_1
assert queued[0].incoming_parent_id == 303 # docker_host_2
# ---------------------------------------------------------------------------
# Clean insert (no collision)
# ---------------------------------------------------------------------------
class TestCleanInsert:
def setup_method(self):
reset_cache()
add_soas(401, "festinger", saliency=1.2)
add_soas(402, "middleware", saliency=0.8)
add_soas(1, "type", saliency=0.0)
@pytest.mark.asyncio
async def test_successful_insert_updates_cache(self):
pool, conn = make_mock_pool()
req = InsertRequest(
concept_id=401, parent_id=402, dim_id=1,
is_isa=True, confidence=0.9, source="test",
)
collision = await insert_urd_edge(pool, req)
assert collision is None
edge = cache.urd_by_concept_dim.get((401, 1))
assert edge is not None
assert edge.parent_id == 402
assert edge.is_isa is True
@pytest.mark.asyncio
async def test_successful_insert_calls_postgres(self):
pool, conn = make_mock_pool()
req = InsertRequest(
concept_id=401, parent_id=402, dim_id=1,
is_isa=True, confidence=0.9, source="test",
)
await insert_urd_edge(pool, req)
conn.execute.assert_called_once()
call_args = conn.execute.call_args[0]
assert "INSERT INTO urd" in call_args[0]
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the relationship cue scanner — ISA/ISPART patterns and of-Z modifier."""
import pytest
from festinger.cue_scanner import scan_cues
def _find(triples, subj, parent, dim=None, is_isa=None):
for t in triples:
if t.subject != subj or t.parent != parent:
continue
if dim is not None and t.dimension != dim:
continue
if is_isa is not None and t.is_isa != is_isa:
continue
return t
return None
# ---------------------------------------------------------------------------
# ISA patterns
# ---------------------------------------------------------------------------
def test_is_a_pattern():
triples = scan_cues("gnommoweb is a repo")
t = _find(triples, "gnommoweb", "repo", is_isa=True)
assert t is not None
assert t.dimension == "type"
def test_is_an_pattern():
triples = scan_cues("gnommoweb is an api")
t = _find(triples, "gnommoweb", "api", is_isa=True)
assert t is not None
def test_isa_explicit():
triples = scan_cues("gnommoweb ISA repo")
t = _find(triples, "gnommoweb", "repo", is_isa=True)
assert t is not None
assert t.confidence >= 0.9
def test_of_z_dimension_modifier():
# "is a repo of Glitch University" → dim = glitch_university
triples = scan_cues("gnommoweb is a repo of Glitch University")
t = _find(triples, "gnommoweb", "repo", is_isa=True)
assert t is not None
assert t.dimension == "glitch_university"
def test_is_a_state_of_usa():
# Core scenario A trigger pattern
triples = scan_cues("michigan is a state of USA")
t = _find(triples, "michigan", "state", is_isa=True)
assert t is not None
assert t.dimension == "usa"
def test_is_a_kind_of():
triples = scan_cues("gnommoweb is a kind of service")
t = _find(triples, "gnommoweb", "service", is_isa=True)
assert t is not None
# ---------------------------------------------------------------------------
# ISPART patterns
# ---------------------------------------------------------------------------
def test_is_part_of():
triples = scan_cues("gnommoweb is part of Glitch University")
t = _find(triples, "gnommoweb", "glitch_university", is_isa=False)
assert t is not None
def test_runs_on():
triples = scan_cues("gnommoweb runs on Docker")
t = _find(triples, "gnommoweb", "docker", is_isa=False)
assert t is not None
assert t.dimension == "runs-on"
def test_belongs_to():
triples = scan_cues("gnommoweb belongs to Agent0")
t = _find(triples, "gnommoweb", "agent0", is_isa=False)
assert t is not None
def test_is_owned_by():
triples = scan_cues("gnommoweb is owned by jenstandstad")
t = _find(triples, "gnommoweb", "jenstandstad", is_isa=False)
assert t is not None
assert t.dimension == "owned-by"
def test_deployed_on():
triples = scan_cues("dobby deployed on docker_host_2")
t = _find(triples, "dobby", "docker_host_2", is_isa=False)
assert t is not None
assert t.dimension == "runs-on"
# ---------------------------------------------------------------------------
# No false positives
# ---------------------------------------------------------------------------
def test_no_match_plain_sentence():
triples = scan_cues("please update the configuration file")
assert triples == []
def test_deduplication():
# Same triple from multiple overlapping patterns should appear once
triples = scan_cues("gnommoweb is a repo")
matches = [t for t in triples if t.subject == "gnommoweb" and t.parent == "repo"]
# May match both "is a" and "is an" patterns but should deduplicate
assert len(matches) <= 2 # at most one per distinct pattern type
@@ -0,0 +1,133 @@
"""
Recollection rendering tests — hit path, zero-hit path, pending-conflict marker,
and prompt injection position.
"""
import pytest
from festinger import cache
from festinger.recollection import (
query_edges, render_hit, render_zero_hit,
build_recollection_block, inject_recollection,
)
from tests.helpers import reset_cache, add_soas, add_urd
class TestHitPath:
def setup_method(self):
reset_cache()
self.michigan = add_soas(101, "michigan", saliency=1.5)
self.usa = add_soas(102, "usa", saliency=0.8)
self.geography = add_soas(5, "geography", saliency=0.0)
self.state = add_soas(103, "state", saliency=0.7)
self.type_dim = add_soas(1, "type", saliency=0.0)
add_urd(concept_id=101, parent_id=102, dim_id=5, is_isa=False, confidence=0.9)
add_urd(concept_id=101, parent_id=103, dim_id=1, is_isa=True, confidence=0.9)
def test_query_returns_both_edges(self):
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
assert len(edges) == 2
def test_render_hit_format(self):
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
line = render_hit("michigan", edges, concept_id=101)
assert line.startswith("michigan:")
assert "[geography] usa" in line
assert "[type] state" in line
def test_pending_conflict_adds_question_mark(self):
cache.pending_conflicts.add(101)
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
line = render_hit("michigan", edges, concept_id=101)
# All dim labels should have ? when concept has pending conflict
assert "[geography?]" in line or "[type?]" in line
def test_confidence_floor_filters_edges(self):
# Add a low-confidence edge
add_urd(concept_id=101, parent_id=5, dim_id=5, is_isa=False, confidence=0.2)
edges = query_edges(101, confidence_floor=0.6, recency_days=90)
# Should not include the 0.2 confidence edge
low_conf = [e for e in edges if e.confidence < 0.6]
assert low_conf == []
def test_block_contains_recollection_tags(self):
block = build_recollection_block([101], confidence_floor=0.5, recency_days=90)
assert block is not None
assert block.startswith("<recollection>")
assert block.endswith("</recollection>")
class TestZeroHitPath:
def setup_method(self):
reset_cache()
add_soas(201, "ramanujan", saliency=1.5)
# No URD edges for ramanujan
def test_zero_hit_render(self):
line = render_zero_hit("ramanujan")
assert "ramanujan" in line
assert "no recollection" in line
assert "gutask iknowthat" in line
def test_zero_hit_in_block(self):
block = build_recollection_block([201], confidence_floor=0.5, recency_days=90)
assert block is not None
assert "? ramanujan" in block
assert "gutask iknowthat" in block
def test_block_is_none_when_no_salient_concepts(self):
block = build_recollection_block([], confidence_floor=0.5, recency_days=90)
assert block is None
class TestPromptInjection:
def setup_method(self):
reset_cache()
add_soas(101, "michigan", saliency=1.5)
add_soas(102, "usa", saliency=0.8)
add_soas(5, "geography", saliency=0.0)
add_urd(concept_id=101, parent_id=102, dim_id=5, is_isa=False, confidence=0.9)
def _block(self):
return build_recollection_block([101], confidence_floor=0.5, recency_days=90)
def test_injected_into_existing_system_message(self):
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me about michigan."},
]
block = self._block()
result = inject_recollection(messages, block)
system = next(m for m in result if m["role"] == "system")
assert system["content"].startswith("<recollection>")
assert "You are a helpful assistant." in system["content"]
def test_injected_at_position_0_when_no_system_message(self):
messages = [{"role": "user", "content": "Tell me about michigan."}]
block = self._block()
result = inject_recollection(messages, block)
assert result[0]["role"] == "system"
assert "<recollection>" in result[0]["content"]
assert result[1]["role"] == "user"
def test_original_messages_not_mutated(self):
original = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "michigan?"},
]
inject_recollection(original, self._block())
# Original list and dicts must be unchanged
assert original[0]["content"] == "You are helpful."
def test_user_message_preserved_after_injection(self):
messages = [
{"role": "system", "content": "System prompt."},
{"role": "user", "content": "michigan?"},
]
result = inject_recollection(messages, self._block())
user_msgs = [m for m in result if m["role"] == "user"]
assert user_msgs[0]["content"] == "michigan?"
@@ -0,0 +1,201 @@
"""
Scenario A — full integration walk-through (in-memory only, no DB required).
Demonstrates the degenerate parent_id=dim_id pattern and the misclassification
collision pipeline from initial seed through recollection rendering.
Story:
1. World model receives coarse early knowledge: "michigan is in usa"
→ stored as michigan ISPART usa in dim:usa (parent_id = dim_id)
2. Agent sends prompt: "michigan is a state of USA"
→ cue scanner extracts michigan ISA state in dim:usa
3. Collision detected: existing ISPART vs incoming ISA in dim:usa → misclassification
4. Recollection rendered with [usa?] marker while conflict is pending
5. After resolution (simulated): fact moves to correct dimension
→ michigan ISA state in dim:type
→ michigan ISPART usa in dim:geography
"""
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from festinger import cache
from festinger.cache import SoasRow, UrdEdge
from festinger.cue_scanner import scan_cues
from festinger.recollection import (
build_recollection_block, inject_recollection, render_hit, query_edges
)
from festinger.urd_writer import InsertRequest, insert_urd_edge, CollisionInfo
from tests.helpers import reset_cache, add_soas, add_urd
def make_mock_pool():
conn = AsyncMock()
conn.execute = AsyncMock(return_value="INSERT 0 1")
conn.fetchrow = AsyncMock(return_value=None)
conn.__aenter__ = AsyncMock(return_value=conn)
conn.__aexit__ = AsyncMock(return_value=False)
pool = MagicMock()
pool.acquire = MagicMock(return_value=conn)
return pool, conn
class TestScenarioAIntegration:
def setup_method(self):
reset_cache()
# SOAS vocabulary
self.michigan = add_soas(101, "michigan", saliency=1.5, novelty=1.0)
self.usa = add_soas(102, "usa", saliency=0.8, novelty=0.8)
self.state = add_soas(103, "state", saliency=0.6, novelty=0.5)
self.type_dim = add_soas(1, "type", saliency=0.0)
self.geo_dim = add_soas(5, "geography", saliency=0.0)
# Degenerate seed edge: michigan ISPART usa, dim=usa (parent_id = dim_id = 102)
self.seed_edge = add_urd(
concept_id=101,
parent_id=102, # usa
dim_id=102, # usa — same as parent_id
is_isa=False,
confidence=0.85,
source="test",
)
# ------------------------------------------------------------------
# Step 1: Verify the degenerate seed state
# ------------------------------------------------------------------
def test_seed_edge_has_parent_id_equals_dim_id(self):
edge = cache.urd_by_concept_dim.get((101, 102))
assert edge is not None, "seed edge must be in cache"
assert edge.parent_id == edge.dim_id, (
f"degenerate edge requires parent_id == dim_id, "
f"got parent_id={edge.parent_id}, dim_id={edge.dim_id}"
)
assert edge.is_isa is False
def test_seed_recollection_renders_correctly(self):
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
assert len(edges) == 1
line = render_hit("michigan", edges, concept_id=101)
# dim token = "usa", parent token = "usa"
assert "[usa] usa" in line
# ------------------------------------------------------------------
# Step 2: Cue scanner extracts the incoming ISA triple
# ------------------------------------------------------------------
def test_cue_scanner_extracts_michigan_isa_state(self):
prompt = "michigan is a state of USA"
triples = scan_cues(prompt)
match = next(
(t for t in triples if t.subject == "michigan" and t.parent == "state"),
None,
)
assert match is not None, "cue scanner must extract michigan ISA state"
assert match.is_isa is True
assert match.dimension == "usa" # from "of USA" modifier
# ------------------------------------------------------------------
# Step 3: Incoming ISA in dim:usa collides with existing ISPART in dim:usa
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_collision_classified_as_misclassification(self):
pool, _ = make_mock_pool()
captured: list[CollisionInfo] = []
async def fake_queue(pool, col, priority):
captured.append(col)
cache.pending_conflicts.add(col.concept_id)
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
req = InsertRequest(
concept_id=101, # michigan
parent_id=103, # state
dim_id=102, # usa — same dim as existing ISPART
is_isa=True,
confidence=0.85,
source="gutask",
)
collision = await insert_urd_edge(pool, req)
assert collision is not None
assert collision.collision_type == "misclassification", (
f"expected misclassification, got {collision.collision_type!r}. "
f"existing is_isa={self.seed_edge.is_isa}, incoming is_isa=True"
)
# ------------------------------------------------------------------
# Step 4: Recollection renders the [usa?] pending-conflict marker
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_recollection_shows_pending_marker_after_collision(self):
pool, _ = make_mock_pool()
async def fake_queue(pool, col, priority):
cache.pending_conflicts.add(col.concept_id)
with patch("festinger.urd_writer._queue_collision", side_effect=fake_queue):
req = InsertRequest(
concept_id=101, parent_id=103, dim_id=102,
is_isa=True, confidence=0.85, source="gutask",
)
await insert_urd_edge(pool, req)
assert 101 in cache.pending_conflicts
block = build_recollection_block([101], confidence_floor=0.5, recency_days=90)
assert block is not None
assert "[usa?]" in block, (
f"recollection must show [usa?] pending marker. Got:\n{block}"
)
# ------------------------------------------------------------------
# Step 5: Simulate resolution — move facts to correct dimensions
# After nightly job:
# michigan ISA state in dim:type (the ISA fact)
# michigan ISPART usa in dim:geography (the ISPART fact, moved off degenerate dim)
# ------------------------------------------------------------------
def test_recollection_clean_after_simulated_resolution(self):
# Remove the degenerate edge
del cache.urd_by_concept_dim[(101, 102)]
cache.urd_by_concept[101] = []
# Insert two correctly-dimensioned edges
geo_edge = add_urd(
concept_id=101, parent_id=102, dim_id=5, # geography dim
is_isa=False, confidence=0.9, source="festinger",
)
type_edge = add_urd(
concept_id=101, parent_id=103, dim_id=1, # type dim
is_isa=True, confidence=0.9, source="festinger",
)
cache.pending_conflicts.discard(101)
edges = query_edges(101, confidence_floor=0.5, recency_days=90)
assert len(edges) == 2
line = render_hit("michigan", edges, concept_id=101)
assert "[geography] usa" in line
assert "[type] state" in line
assert "?" not in line, "no pending marker after resolution"
def test_full_recollection_block_after_resolution(self):
# Simulate post-resolution state
del cache.urd_by_concept_dim[(101, 102)]
cache.urd_by_concept[101] = []
add_urd(concept_id=101, parent_id=102, dim_id=5, is_isa=False, confidence=0.9, source="festinger")
add_urd(concept_id=101, parent_id=103, dim_id=1, is_isa=True, confidence=0.9, source="festinger")
cache.pending_conflicts.discard(101)
block = build_recollection_block([101], confidence_floor=0.5, recency_days=90)
assert block is not None
assert "<recollection>" in block
assert "michigan:" in block
assert "[geography] usa" in block
assert "[type] state" in block
# No pending markers
assert "?" not in block
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the tokeniser — compound token rule, punctuation stripping, length filter."""
import pytest
from festinger.tokenizer import tokenize, tokenize_all
def test_simple_tokens():
# "repo" is 4 chars — filtered by the ≥5 rule. Use a longer word.
tokens = tokenize("gnommoweb is a repository")
assert "gnommoweb" in tokens
assert "repository" in tokens
assert "repo" not in tokens # 4 chars — below threshold
def test_compound_token_rule():
tokens = tokenize("Glitch University runs on Docker")
assert "glitch_university" in tokens
assert "docker" in tokens
# Individual parts should NOT appear as separate tokens
assert "glitch" not in tokens
assert "university" not in tokens
def test_multi_word_compound():
tokens = tokenize("New York City is a place")
assert "new_york_city" in tokens
def test_lowercase_breaks_compound_run():
# "the" breaks the run — "Glitch University" still merges
tokens = tokenize("the Glitch University system")
assert "glitch_university" in tokens
assert "system" in tokens
assert "glitch" not in tokens
def test_length_filter():
# Tokens < 5 chars are dropped
tokens = tokenize("cat dog bird eagle")
assert "eagle" in tokens
assert "bird" not in tokens
assert "cat" not in tokens
assert "dog" not in tokens
def test_punctuation_stripped():
# Trailing punctuation (period, colon) breaks the compound run.
# "FastAPI." ends a run immediately; "Docker:" starts and ends a fresh run.
tokens = tokenize("gnommoweb, FastAPI. Docker:")
assert "gnommoweb" in tokens
assert "fastapi" in tokens # from "FastAPI." — flushed as solo compound
assert "docker" in tokens # from "Docker:" — flushed as solo compound
# Must NOT merge across sentence boundaries
assert "fastapi_docker" not in tokens
def test_deduplication():
tokens = tokenize("gnommoweb gnommoweb gnommoweb")
assert tokens.count("gnommoweb") == 1
def test_empty_string():
assert tokenize("") == []
def test_tokenize_all_no_length_filter():
# tokenize_all keeps short tokens
tokens = tokenize_all("is a part of")
assert "is" in tokens
assert "of" in tokens
assert "part" in tokens
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
WordNet Release 3.0 This software and database is being provided to you, the LICENSEE, by Princeton University under the following license. By obtaining, using and/or copying this software and database, you agree that you have read, understood, and will comply with these terms and conditions.: Permission to use, copy, modify and distribute this software and database and its documentation for any purpose and without fee or royalty is hereby granted, provided that you agree to comply with the following copyright notice and statements, including the disclaimer, and that the same appear on ALL copies of the software, database and documentation, including modifications that you make for internal use or for distribution. WordNet 3.0 Copyright 2006 by Princeton University. All rights reserved. THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PRINCETON UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES OF MERCHANT- ABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE LICENSED SOFTWARE, DATABASE OR DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. The name of Princeton University or Princeton may not be used in advertising or publicity pertaining to distribution of the software and/or database. Title to copyright in this software, database and any associated documentation shall at all times remain with Princeton University and LICENSEE agrees to preserve same.