Add basic version: SemIf decision engine, urgency queue, skill tree, skill loop, decision log, dream pass, CLI
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
data/decisions.jsonl
|
||||||
|
data/drafts/
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Semif Agent
|
||||||
|
|
||||||
|
A local desktop CLI agent whose control flow is a single decision model (SemIf). Inputs are gated, scored, queued, and dispatched through a skill tree; every decision is logged as a labeled training row, and the decision model is fine-tuned at regular intervals on the accumulated prediction-vs-observation cost.
|
||||||
|
|
||||||
|
## Engine: SemIf (local decision layer)
|
||||||
|
|
||||||
|
- **SemIf** (formerly OpenJev, TheoLeeCJ, MIT) is a local rebuild of Jev's interface pattern. One forward pass reads typed option logits directly from a model; no answer sentence, no decoding loop.
|
||||||
|
- Contract: input `{state, question, options[]}`, output per-option probabilities conditional on the supplied options. Probabilities are conditional on the option set, not absolute confidence — calibrate per workload (SemIf ships per-workload temperature scaling).
|
||||||
|
- **Why local**: desktop target, data stays in-network (SemIf's data boundary), runs on a home GPU (or CPU via llama.cpp). Jev (hosted) is not in scope.
|
||||||
|
- Decision model is pinned to an exact revision; every swap is auditable via prompt hashes in SemIf's output.
|
||||||
|
|
||||||
|
## Core loop
|
||||||
|
|
||||||
|
```
|
||||||
|
intake (typed / events / timers / other skills)
|
||||||
|
└─ contains_request? (gate: is this even actionable?)
|
||||||
|
└─ choice (SemIf: "should this interrupt the current process?")
|
||||||
|
├─ yes → preempt current, requeue it with state preserved
|
||||||
|
└─ no → score (SemIf: "how urgent?")
|
||||||
|
└─ priority queue, sorted by urgency desc
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Scheduler**: run head of queue when idle. A new interrupt displaces the current process, which itself requeues preserving its state.
|
||||||
|
- **Concurrency** (TODO resolved): single execution slot for v1. SemIf's shared-state mode (one prefetched state branched across many criteria in parallel) is the lever for parallelizing decisions later, not concurrent process execution.
|
||||||
|
- **Tie-breaking**: FIFO within equal urgency, recency as secondary key.
|
||||||
|
- **Priority ageing**: queued items decay upward over time so they can't starve.
|
||||||
|
- **Bounds**: max queue depth; overflow rejects with a notification.
|
||||||
|
|
||||||
|
## Decision templates
|
||||||
|
|
||||||
|
All decisions are SemIf calls: `{state, question, options[]}`. State is the current process + input. Options are typed with descriptions.
|
||||||
|
|
||||||
|
- **`choice`** — binary: `interrupt` / `defer`. Interrupt iff `P(interrupt) >= τ`.
|
||||||
|
- **`score`** — ordinal urgency: `critical` / `high` / `medium` / `low`, mapped to numeric weights for sorting.
|
||||||
|
- **skill navigation** — at each tree level: choose category / descend / `create_skill`.
|
||||||
|
- **`read_next()`** — argument selection within a skill (e.g., which contact is "girlfriend").
|
||||||
|
|
||||||
|
**LLM/SemIf boundary**: SemIf for fast, repeated, low-latency decisions (gating, scoring, routing, argument selection). LLM for generation and assessment (email body, self-assessment summary). Never the reverse.
|
||||||
|
|
||||||
|
## Skill tree
|
||||||
|
|
||||||
|
- Structure: categories → skills → actions. Top level listed at each level.
|
||||||
|
- Navigation is a chain of SemIf choices, one per level, descending until a leaf skill matches.
|
||||||
|
- At each level a `create_skill` branch exists: **opencode authors the skill** (its only role) and drops a skill manifest into the registry. The new skill becomes a leaf immediately.
|
||||||
|
|
||||||
|
### Skill manifest
|
||||||
|
- name, category, description, allowed inputs, action list, cost budget, decision log reference.
|
||||||
|
|
||||||
|
## Skill anatomy
|
||||||
|
|
||||||
|
Every skill run follows the same loop:
|
||||||
|
|
||||||
|
1. **observe baseline** — capture state relevant to the skill.
|
||||||
|
2. **make prediction** — predict outcome; log it.
|
||||||
|
3. **act** — execute actions (possibly calling `read_next()`-style SemIf decisions for arguments).
|
||||||
|
4. **observe outcome** — capture post-action state.
|
||||||
|
5. **self-assess** (LLM) — "have I succeeded?" → success/failure + summary + **updated request** back to the priority queue.
|
||||||
|
|
||||||
|
- The self-assessment result **labels** every SemIf decision made during that run (`choice`, `score`, navigation, argument selection): the predicted distribution vs. the observed outcome is one labeled training row.
|
||||||
|
- Per-skill cost budget: a run that exceeds it fails fast and requeues with a degraded goal.
|
||||||
|
|
||||||
|
## Learning / fine-tuning pipeline
|
||||||
|
|
||||||
|
- **Log**: every decision row → `{state, question, options, predicted_probs, observed_outcome}` — SemIf `decisions.jsonl`-compatible, so logs replay directly into the scorer.
|
||||||
|
- **Cost function**: loss between the predicted distribution and the observed outcome — cross-entropy / NLL under a proper scoring rule, plus optional calibration loss. This is the training signal.
|
||||||
|
- **Labels**: self-assessment output by default; human confirmations/corrections override and become high-weight labels.
|
||||||
|
- **"Dreaming"** — a cronjob at a regular interval: accumulate logged rows → compute cost over the period → fine-tune the decision model on them → CI/CD validates (accuracy / ECE on a held-out slice, prompt-hash regression) → swap in the new pinned model revision.
|
||||||
|
- **GPU offload**: training runs on a beefier GPU; the running agent keeps a frozen inference revision until a swap validates.
|
||||||
|
- Cold start: no labels yet — seed from self-assessed runs only; human overrides accelerate early calibration.
|
||||||
|
|
||||||
|
## Worked examples
|
||||||
|
|
||||||
|
### Example 1
|
||||||
|
- current_process: none
|
||||||
|
- input: "send my girlfriend an email that says that I'm going to be late to the party"
|
||||||
|
- contains_request: yes
|
||||||
|
- interrupt_current: yes
|
||||||
|
- skill_selection: email => compose => transcribe(input): [code block that uses SemIf `read_next()` to determine "girlfriend" from contacts, plus a normally functioning LLM for the email body]
|
||||||
|
|
||||||
|
### Example 2
|
||||||
|
- current_process: driving car
|
||||||
|
- input: "send my girlfriend an email that says that I'm going to be late to the party"
|
||||||
|
- interrupt_current: no
|
||||||
|
- priority: .21
|
||||||
|
- skill_selection: response => rejection(input)
|
||||||
|
|
||||||
|
### Example 3
|
||||||
|
- current_process: driving car
|
||||||
|
- input: "holy shit stop!"
|
||||||
|
- contains_request: yes
|
||||||
|
- interrupt_current: yes
|
||||||
|
- skill_selection: driving => decelerate(input)
|
||||||
|
|
||||||
|
### Example 4 (skill creation)
|
||||||
|
- current_process: none
|
||||||
|
- input: "tell me if my package was delivered"
|
||||||
|
- contains_request: yes
|
||||||
|
- interrupt_current: yes
|
||||||
|
- skill_selection: tracking => (no leaf) => create_skill
|
||||||
|
- opencode authors `tracking.check_delivery` from the input + registry conventions → manifest registered → requeue → skill_selection: tracking => check_delivery(input)
|
||||||
|
|
||||||
|
### Example 5 (self-assessment + dream)
|
||||||
|
- current_process: email compose
|
||||||
|
- input: "send my girlfriend an email…" → email compose succeeds but sends to the wrong contact (`read_next` picked wrong)
|
||||||
|
- self-assess: fail → labels the `read_next` decision row (predicted_probs vs wrong outcome)
|
||||||
|
- cost computed, logged
|
||||||
|
- next dream cycle: row included in fine-tune → model revision swapped → `read_next` recalibrated
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Safety / authority**: which inputs may interrupt high-stakes processes (driving-grade)? Is interrupt a per-skill permission, not a global default?
|
||||||
|
- **Queue persistence**: is the priority queue in-memory or durable across restarts?
|
||||||
|
- **Input taxonomy**: enumerate intake sources and their `contains_request` gating semantics.
|
||||||
|
- **Privacy boundary**: all data stays local — confirm no telemetry even for dreaming.
|
||||||
|
- **Override UX**: how do human confirmations/corrections get surfaced and captured cheaply?
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"tau": 0.6,
|
||||||
|
"max_reentries": 3,
|
||||||
|
"queue": {"max_size": 100, "age_rate": 0.01},
|
||||||
|
"engine": {
|
||||||
|
"backend": "llamacpp",
|
||||||
|
"source": "Qwen/Qwen3.5-4B",
|
||||||
|
"revision": "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a",
|
||||||
|
"gguf": "/mnt/models/Qwen3.5-4B-Q4_K_M.gguf",
|
||||||
|
"context_tokens": 4096,
|
||||||
|
"threads": null
|
||||||
|
},
|
||||||
|
"llm": {"base_url": "http://localhost:11434/v1", "model": "qwen2.5:3b"},
|
||||||
|
"skills": {
|
||||||
|
"email": {"cost_budget": 1.0},
|
||||||
|
"contacts": "data/contacts.json",
|
||||||
|
"drafts": "data/drafts",
|
||||||
|
"packages": "data/packages.json"
|
||||||
|
},
|
||||||
|
"log": "data/decisions.jsonl"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
{"name": "alice", "description": "Partner; usually home by six."},
|
||||||
|
{"name": "bob", "description": "Work colleague on the ops team."},
|
||||||
|
{"name": "carol", "description": "Landlord; prefers email."}
|
||||||
|
]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
{"id": "1Z999AA10123456784", "status": "out for delivery"},
|
||||||
|
{"id": "1Z999BB10234567891", "status": "delivered"},
|
||||||
|
{"id": "1Z999CC10345678908", "status": "at sorting facility"}
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "semif-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A local desktop agent whose control flow is one decision model (SemIf)."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = ["pytest"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["semif_agent*"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Semif agent: a local desktop agent whose control flow is one decision model (SemIf)."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""CLI entrypoint: interactive REPL, scripted JSONL mode, and subcommands.
|
||||||
|
|
||||||
|
Run on the box with SemIf + a GGUF + a local OpenAI-compatible server:
|
||||||
|
|
||||||
|
python -m semif_agent.cli run # REPL
|
||||||
|
python -m semif_agent.cli run --script inputs.jsonl
|
||||||
|
python -m semif_agent.cli dream # prediction-observation cost report
|
||||||
|
python -m semif_agent.cli skills
|
||||||
|
python -m semif_agent.cli status
|
||||||
|
python -m semif_agent.cli relabel <id> <outcome>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .dream import dream as run_dream
|
||||||
|
from .engine import EngineConfig, EngineUnavailable, SemIfEngine
|
||||||
|
from .llm import LLMClient
|
||||||
|
from .log import DecisionLog
|
||||||
|
from .scheduler import Scheduler
|
||||||
|
from .skills import build_skills, build_tree, tree_summary
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str = "config.json") -> dict:
|
||||||
|
return json.loads(Path(path).read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def build_scheduler(config: dict) -> tuple[Scheduler, dict]:
|
||||||
|
engine = SemIfEngine(
|
||||||
|
EngineConfig(
|
||||||
|
backend=config.get("engine", {}).get("backend", "llamacpp"),
|
||||||
|
source=config.get("engine", {}).get("source", ""),
|
||||||
|
revision=config.get("engine", {}).get("revision", ""),
|
||||||
|
gguf=config.get("engine", {}).get("gguf", ""),
|
||||||
|
context_tokens=int(config.get("engine", {}).get("context_tokens", 4096)),
|
||||||
|
threads=config.get("engine", {}).get("threads"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
llm = LLMClient(
|
||||||
|
base_url=config.get("llm", {}).get("base_url", "http://localhost:11434/v1"),
|
||||||
|
model=config.get("llm", {}).get("model", "qwen2.5:3b"),
|
||||||
|
)
|
||||||
|
log = DecisionLog(config.get("log", "data/decisions.jsonl"))
|
||||||
|
scheduler = Scheduler(
|
||||||
|
engine=engine,
|
||||||
|
llm=llm,
|
||||||
|
log=log,
|
||||||
|
config=config,
|
||||||
|
tau=float(config.get("tau", 0.6)),
|
||||||
|
max_reentries=int(config.get("max_reentries", 3)),
|
||||||
|
)
|
||||||
|
return scheduler, config
|
||||||
|
|
||||||
|
|
||||||
|
def try_warm(scheduler: Scheduler) -> str:
|
||||||
|
try:
|
||||||
|
scheduler.engine._ensure_loaded()
|
||||||
|
return "decision engine loaded."
|
||||||
|
except EngineUnavailable as exc:
|
||||||
|
return f"decision engine unavailable: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def repl(scheduler: Scheduler, config: dict) -> None:
|
||||||
|
print(try_warm(scheduler))
|
||||||
|
print("type a request, or one of: busy <text> | idle | status | skills | dream | relabel <id> <outcome> | quit")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line = input("> ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
lower = line.lower()
|
||||||
|
if lower in ("quit", "exit"):
|
||||||
|
break
|
||||||
|
if lower == "status":
|
||||||
|
print(scheduler.status())
|
||||||
|
continue
|
||||||
|
if lower == "skills":
|
||||||
|
print(tree_summary(build_tree(build_skills(config))))
|
||||||
|
continue
|
||||||
|
if lower == "dream":
|
||||||
|
print(run_dream(scheduler.log).render())
|
||||||
|
continue
|
||||||
|
if lower.startswith("relabel "):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 3:
|
||||||
|
print("usage: relabel <id> <outcome>")
|
||||||
|
continue
|
||||||
|
ok = scheduler.log.relabel(parts[1], parts[2])
|
||||||
|
print("relabeled." if ok else f"no row with id {parts[1]}")
|
||||||
|
continue
|
||||||
|
if lower == "idle":
|
||||||
|
scheduler.idle()
|
||||||
|
print("current process cleared.")
|
||||||
|
continue
|
||||||
|
if lower.startswith("busy "):
|
||||||
|
scheduler.busy(line[5:].strip())
|
||||||
|
print("current process set (busy).")
|
||||||
|
continue
|
||||||
|
status, detail = scheduler.submit(line)
|
||||||
|
print(f"[{status}] {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def scripted(scheduler: Scheduler, path: str) -> None:
|
||||||
|
print(try_warm(scheduler))
|
||||||
|
rows = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
|
||||||
|
for row in rows:
|
||||||
|
status, detail = scheduler.submit(str(row["text"]), source=row.get("source", "scripted"))
|
||||||
|
print(f"[{status}] {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="semif-agent")
|
||||||
|
parser.add_argument("--config", default="config.json")
|
||||||
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
run_p = sub.add_parser("run", help="interactive REPL or scripted input")
|
||||||
|
run_p.add_argument("--script", default=None, help="JSONL file of {\"text\": ...} rows")
|
||||||
|
|
||||||
|
sub.add_parser("dream", help="compute the prediction-observation cost report")
|
||||||
|
sub.add_parser("skills", help="list the skill tree")
|
||||||
|
sub.add_parser("status", help="show current process and queue")
|
||||||
|
|
||||||
|
relabel_p = sub.add_parser("relabel", help="human override of a decision label")
|
||||||
|
relabel_p.add_argument("id")
|
||||||
|
relabel_p.add_argument("outcome")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
config = load_config(args.config)
|
||||||
|
scheduler, config = build_scheduler(config)
|
||||||
|
|
||||||
|
if args.command == "run":
|
||||||
|
if args.script:
|
||||||
|
scripted(scheduler, args.script)
|
||||||
|
else:
|
||||||
|
repl(scheduler, config)
|
||||||
|
elif args.command == "dream":
|
||||||
|
print(run_dream(scheduler.log).render())
|
||||||
|
elif args.command == "skills":
|
||||||
|
print(tree_summary(build_tree(build_skills(config))))
|
||||||
|
elif args.command == "status":
|
||||||
|
print(scheduler.status())
|
||||||
|
elif args.command == "relabel":
|
||||||
|
ok = scheduler.log.relabel(args.id, args.outcome)
|
||||||
|
print("relabeled." if ok else f"no row with id {args.id}")
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""The SemIf decision contract shared across the agent.
|
||||||
|
|
||||||
|
A decision is a typed question over a state with declared options; SemIf returns
|
||||||
|
probabilities conditional on exactly the supplied options. These are not
|
||||||
|
calibrated confidence values, so callers treat them as conditional scores.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Option:
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DecisionRequest:
|
||||||
|
"""One SemIf decision: state + question + typed options."""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
question: str
|
||||||
|
options: list[Option]
|
||||||
|
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||||
|
|
||||||
|
def to_semif_row(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"state": self.state,
|
||||||
|
"question": self.question,
|
||||||
|
"options": [{"id": o.id, "description": o.description} for o in self.options],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DecisionResult:
|
||||||
|
"""The outcome of one SemIf call: probabilities aligned to option ids."""
|
||||||
|
|
||||||
|
request: DecisionRequest
|
||||||
|
option_ids: list[str]
|
||||||
|
probabilities: list[float]
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def probs(self) -> dict[str, float]:
|
||||||
|
return dict(zip(self.option_ids, self.probabilities))
|
||||||
|
|
||||||
|
def prob(self, option_id: str) -> float:
|
||||||
|
index = self.option_ids.index(option_id)
|
||||||
|
return self.probabilities[index]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected(self) -> str:
|
||||||
|
return max(self.probs, key=self.probs.get)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Request:
|
||||||
|
"""An incoming input to the agent, before it is gated/scored."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||||
|
source: str = "typed"
|
||||||
|
meta: dict[str, Any] = field(default_factory=dict)
|
||||||
|
received_at: float = field(default_factory=time.time)
|
||||||
|
priority: float = 0.5
|
||||||
|
reentries: int = 0
|
||||||
|
resume: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def copy_for_requeue(self) -> "Request":
|
||||||
|
return Request(
|
||||||
|
text=self.text,
|
||||||
|
id=self.id,
|
||||||
|
source=self.source,
|
||||||
|
meta=dict(self.meta),
|
||||||
|
received_at=self.received_at,
|
||||||
|
priority=self.priority,
|
||||||
|
reentries=self.reentries + 1,
|
||||||
|
resume=dict(self.resume),
|
||||||
|
)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""The dream pass: compute the prediction-observation cost over the decision log.
|
||||||
|
|
||||||
|
Replays `decisions.jsonl` and reports the training signal for the fine-tuning
|
||||||
|
step: per-row negative log likelihood (NLL) of the observed outcome under the
|
||||||
|
predicted distribution, overall cross-entropy, accuracy, and binned expected
|
||||||
|
calibration error (ECE). Human-override labels are weighted higher, matching
|
||||||
|
the design (self-assessment + human overrides). This computes the signal; the
|
||||||
|
actual fine-tuning and CI/CD model swap are a separate v2 step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .log import DecisionLog
|
||||||
|
|
||||||
|
HUMAN_WEIGHT = 3.0
|
||||||
|
SELF_WEIGHT = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RowCost:
|
||||||
|
decision_id: str
|
||||||
|
predicted: float
|
||||||
|
observed: str
|
||||||
|
selected: str
|
||||||
|
nll: float
|
||||||
|
weight: float
|
||||||
|
correct: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DreamReport:
|
||||||
|
rows: list[RowCost] = field(default_factory=list)
|
||||||
|
skipped: int = 0
|
||||||
|
human_overrides: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cross_entropy(self) -> float | None:
|
||||||
|
if not self.rows:
|
||||||
|
return None
|
||||||
|
total = sum(r.weight for r in self.rows)
|
||||||
|
return sum(r.weight * r.nll for r in self.rows) / total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accuracy(self) -> float | None:
|
||||||
|
if not self.rows:
|
||||||
|
return None
|
||||||
|
return sum(r.correct for r in self.rows) / len(self.rows)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ece(self) -> float | None:
|
||||||
|
"""Binned expected calibration error over confidence of the selected option."""
|
||||||
|
if not self.rows:
|
||||||
|
return None
|
||||||
|
bins: dict[int, list[RowCost]] = {index: [] for index in range(10)}
|
||||||
|
for row in self.rows:
|
||||||
|
bin_index = min(int(row.predicted * 10), 9)
|
||||||
|
bins[bin_index].append(row)
|
||||||
|
total_weight = sum(r.weight for r in self.rows)
|
||||||
|
if total_weight <= 0:
|
||||||
|
return None
|
||||||
|
error = 0.0
|
||||||
|
for index, members in bins.items():
|
||||||
|
if not members:
|
||||||
|
continue
|
||||||
|
weight = sum(r.weight for r in members)
|
||||||
|
confidence = sum(r.predicted * r.weight for r in members) / weight
|
||||||
|
accuracy = sum(r.correct * r.weight for r in members) / weight
|
||||||
|
error += abs(confidence - accuracy) * weight / total_weight
|
||||||
|
return error
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
lines = [
|
||||||
|
f"decision rows: {len(self.rows)} (skipped {self.skipped})",
|
||||||
|
f"human overrides: {self.human_overrides}",
|
||||||
|
]
|
||||||
|
ce = self.cross_entropy
|
||||||
|
acc = self.accuracy
|
||||||
|
ece = self.ece
|
||||||
|
lines.append(f"cross-entropy (weighted): {ce:.4f}" if ce is not None else "cross-entropy: n/a")
|
||||||
|
lines.append(f"accuracy (selected==observed): {acc:.3f}" if acc is not None else "accuracy: n/a")
|
||||||
|
lines.append(f"ECE (10 bins): {ece:.4f}" if ece is not None else "ECE: n/a")
|
||||||
|
if self.rows:
|
||||||
|
worst = sorted(self.rows, key=lambda r: r.nll, reverse=True)[:5]
|
||||||
|
lines.append("highest-cost rows:")
|
||||||
|
for row in worst:
|
||||||
|
lines.append(
|
||||||
|
f" {row.decision_id} pred={row.predicted:.3f} selected={row.selected} "
|
||||||
|
f"observed={row.observed} nll={row.nll:.3f}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def dream(log: DecisionLog) -> DreamReport:
|
||||||
|
report = DreamReport()
|
||||||
|
for row in log.read():
|
||||||
|
probs = row.get("predicted_probs") or {}
|
||||||
|
observed = row.get("observed_outcome")
|
||||||
|
selected = row.get("selected")
|
||||||
|
if observed is None or selected is None or observed not in probs:
|
||||||
|
report.skipped += 1
|
||||||
|
continue
|
||||||
|
predicted = float(probs[observed])
|
||||||
|
if predicted <= 0:
|
||||||
|
predicted = 1e-9
|
||||||
|
nll = -math.log(predicted)
|
||||||
|
weight = HUMAN_WEIGHT if row.get("label_source") == "human" else SELF_WEIGHT
|
||||||
|
if row.get("label_source") == "human":
|
||||||
|
report.human_overrides += 1
|
||||||
|
report.rows.append(
|
||||||
|
RowCost(
|
||||||
|
decision_id=row["id"],
|
||||||
|
predicted=predicted,
|
||||||
|
observed=observed,
|
||||||
|
selected=selected,
|
||||||
|
nll=nll,
|
||||||
|
weight=weight,
|
||||||
|
correct=(selected == observed),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return report
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Real SemIf decision engine (no mocking).
|
||||||
|
|
||||||
|
Wraps the SemIf package (`semif_phase1`). The import and model load happen
|
||||||
|
lazily so the rest of the agent is pure stdlib and testable without SemIf
|
||||||
|
installed. On hardware that is neither CUDA nor Apple, use the shipped
|
||||||
|
llama.cpp backend: a local GGUF scored on CPU (or Vulkan if your llama.cpp
|
||||||
|
build enables it). This engine is only usable on a machine with SemIf and the
|
||||||
|
pinned GGUF available; elsewhere calls raise EngineUnavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .decisions import DecisionRequest, DecisionResult
|
||||||
|
|
||||||
|
|
||||||
|
class EngineUnavailable(RuntimeError):
|
||||||
|
"""SemIf is not installed or the configured model is missing."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EngineConfig:
|
||||||
|
backend: str = "llamacpp"
|
||||||
|
source: str = "Qwen/Qwen3.5-4B"
|
||||||
|
revision: str = ""
|
||||||
|
gguf: str = ""
|
||||||
|
context_tokens: int = 4096
|
||||||
|
threads: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SemIfEngine:
|
||||||
|
"""One pinned SemIf model, loaded once and used for every decision."""
|
||||||
|
|
||||||
|
def __init__(self, config: EngineConfig):
|
||||||
|
self.config = config
|
||||||
|
self._model = None
|
||||||
|
self._tokenizer = None
|
||||||
|
self._metadata = None
|
||||||
|
|
||||||
|
def _ensure_loaded(self) -> tuple:
|
||||||
|
if self._model is not None:
|
||||||
|
return self._model, self._tokenizer, self._metadata
|
||||||
|
if self.config.backend != "llamacpp":
|
||||||
|
raise EngineUnavailable(
|
||||||
|
f"Unsupported backend {self.config.backend!r}; use 'llamacpp'."
|
||||||
|
)
|
||||||
|
if not self.config.gguf or not Path(self.config.gguf).is_file():
|
||||||
|
raise EngineUnavailable(
|
||||||
|
f"GGUF not found: {self.config.gguf!r}. Set engine.gguf in config.json."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from semif_phase1 import llamacpp_backend as backend
|
||||||
|
except ImportError as exc:
|
||||||
|
raise EngineUnavailable(
|
||||||
|
"SemIf is not installed here. Install it on the target box with "
|
||||||
|
"`pip install -e '.[test,llamacpp]'`."
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
model, tokenizer, metadata = backend.load_model(
|
||||||
|
self.config.source,
|
||||||
|
self.config.revision,
|
||||||
|
self.config.gguf,
|
||||||
|
threads=self.config.threads,
|
||||||
|
context_tokens=self.config.context_tokens,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise EngineUnavailable(f"Failed to load the SemIf model: {exc}") from exc
|
||||||
|
self._model, self._tokenizer, self._metadata = model, tokenizer, metadata
|
||||||
|
return model, tokenizer, metadata
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded(self) -> bool:
|
||||||
|
return self._model is not None
|
||||||
|
|
||||||
|
def call(self, request: DecisionRequest) -> DecisionResult:
|
||||||
|
model, tokenizer, metadata = self._ensure_loaded()
|
||||||
|
from semif_phase1 import llamacpp_backend as backend
|
||||||
|
|
||||||
|
row = request.to_semif_row()
|
||||||
|
result = backend.score(model, tokenizer, row, metadata)
|
||||||
|
return DecisionResult(
|
||||||
|
request=request,
|
||||||
|
option_ids=list(result["option_ids"]),
|
||||||
|
probabilities=list(result["probabilities"]),
|
||||||
|
extra={
|
||||||
|
"prompt_sha256": result.get("prompt_sha256"),
|
||||||
|
"input_tokens": result.get("input_tokens"),
|
||||||
|
"forward_seconds": result.get("forward_seconds"),
|
||||||
|
"total_seconds": result.get("total_seconds"),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Self-assessment LLM client.
|
||||||
|
|
||||||
|
Talks to a local OpenAI-compatible server (e.g. ollama, llama.cpp server) for
|
||||||
|
the observe -> assess phase of the skill loop. Real, not mocked; the endpoint
|
||||||
|
must be reachable. Uses only the stdlib HTTP client so the core stays
|
||||||
|
dependency-free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Assessment:
|
||||||
|
success: bool
|
||||||
|
summary: str
|
||||||
|
updated_request: str | None = None
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
def __init__(self, base_url: str, model: str, timeout: float = 120.0):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.model = model
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _chat(self, messages: list[dict], temperature: float = 0.0) -> str:
|
||||||
|
url = f"{self.base_url}/chat/completions"
|
||||||
|
body = json.dumps(
|
||||||
|
{"model": self.model, "messages": messages, "temperature": temperature}
|
||||||
|
).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url, data=body, headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"LLM endpoint unreachable at {url}: {exc}. Is your local server running?"
|
||||||
|
) from exc
|
||||||
|
return payload["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
def assess(self, skill: str, request_text: str, action_log: str) -> Assessment:
|
||||||
|
system = (
|
||||||
|
"You are the self-assessment step of an agent skill run. Decide whether "
|
||||||
|
"the skill achieved its goal. Reply with JSON only: "
|
||||||
|
'{"success": true|false, "summary": "<brief>", "updated_request": '
|
||||||
|
'"<requeued request text or null>"}. success is true only if the goal was met.'
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
f"Skill: {skill}\n"
|
||||||
|
f"Goal request: {request_text}\n"
|
||||||
|
f"What was done:\n{action_log}\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = self._chat(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
parsed = self._parse_json(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
return Assessment(success=False, summary=f"assessment failed: {exc}")
|
||||||
|
success = bool(parsed.get("success"))
|
||||||
|
summary = str(parsed.get("summary", ""))
|
||||||
|
updated = parsed.get("updated_request")
|
||||||
|
return Assessment(
|
||||||
|
success=success,
|
||||||
|
summary=summary,
|
||||||
|
updated_request=None if updated is None else str(updated),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json(raw: str) -> dict:
|
||||||
|
text = raw.strip()
|
||||||
|
start, end = text.find("{"), text.rfind("}")
|
||||||
|
if start != -1 and end != -1:
|
||||||
|
text = text[start : end + 1]
|
||||||
|
return json.loads(text)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Decision logging: every SemIf call is written as a labeled training row.
|
||||||
|
|
||||||
|
Rows match the SemIf `decisions.jsonl` shape plus the prediction-observation
|
||||||
|
cost fields. `observed_outcome` is the label for the cost function; by default
|
||||||
|
it is the option that was actually selected (self-consistent), and a human
|
||||||
|
override can relabel a row to the correct outcome with a higher weight.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .decisions import DecisionRequest, DecisionResult
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionLog:
|
||||||
|
def __init__(self, path: str = "data/decisions.jsonl"):
|
||||||
|
self.path = Path(path)
|
||||||
|
|
||||||
|
def append(
|
||||||
|
self,
|
||||||
|
request: DecisionRequest,
|
||||||
|
result: DecisionResult,
|
||||||
|
label: str | None = None,
|
||||||
|
extra: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Append one decision row. `label` overrides observed_outcome."""
|
||||||
|
observed = label if label is not None else result.selected
|
||||||
|
source = "human" if label is not None else "self"
|
||||||
|
row = {
|
||||||
|
"id": request.id,
|
||||||
|
"ts": time.time(),
|
||||||
|
"state": request.state,
|
||||||
|
"question": request.question,
|
||||||
|
"options": [{"id": o.id, "description": o.description} for o in request.options],
|
||||||
|
"predicted_probs": result.probs,
|
||||||
|
"selected": result.selected,
|
||||||
|
"observed_outcome": observed,
|
||||||
|
"label_source": source,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
row["extra"] = extra
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.path.open("a") as handle:
|
||||||
|
handle.write(json.dumps(row) + "\n")
|
||||||
|
|
||||||
|
def relabel(self, decision_id: str, observed: str) -> bool:
|
||||||
|
"""Human override: set a corrected observed outcome for one row."""
|
||||||
|
rows = self.read()
|
||||||
|
found = False
|
||||||
|
for row in rows:
|
||||||
|
if row["id"] == decision_id:
|
||||||
|
row["observed_outcome"] = observed
|
||||||
|
row["label_source"] = "human"
|
||||||
|
found = True
|
||||||
|
if not found:
|
||||||
|
return False
|
||||||
|
self._write(rows)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def read(self) -> list[dict]:
|
||||||
|
if not self.path.is_file():
|
||||||
|
return []
|
||||||
|
rows = []
|
||||||
|
with self.path.open("r") as handle:
|
||||||
|
for line in handle:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _write(self, rows: list[dict]) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self.path.open("w") as handle:
|
||||||
|
for row in rows:
|
||||||
|
handle.write(json.dumps(row) + "\n")
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Urgency priority queue.
|
||||||
|
|
||||||
|
Items are sorted by descending urgency weight, then FIFO arrival, then most
|
||||||
|
recent first. Queued items age upward so they cannot starve, and the queue has
|
||||||
|
a maximum depth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
import itertools
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from .decisions import Request
|
||||||
|
|
||||||
|
MIN_RECENCY = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QueueItem:
|
||||||
|
weight: float
|
||||||
|
seq: int
|
||||||
|
recency: float
|
||||||
|
request: Request
|
||||||
|
|
||||||
|
def key(self) -> tuple:
|
||||||
|
return (-self.weight, self.seq)
|
||||||
|
|
||||||
|
|
||||||
|
class UrgencyQueue:
|
||||||
|
"""A max-by-urgency priority queue with FIFO/recency tie-breaking."""
|
||||||
|
|
||||||
|
def __init__(self, max_size: int = 100, age_rate: float = 0.0):
|
||||||
|
self.max_size = max_size
|
||||||
|
self.age_rate = age_rate
|
||||||
|
self._entries: list[tuple] = []
|
||||||
|
self._counter = itertools.count()
|
||||||
|
|
||||||
|
def push(self, request: Request, weight: float, recency: float | None = None) -> bool:
|
||||||
|
"""Insert an item. Returns False if the queue is full."""
|
||||||
|
if len(self._entries) >= self.max_size:
|
||||||
|
return False
|
||||||
|
item = QueueItem(
|
||||||
|
weight=weight,
|
||||||
|
seq=next(self._counter),
|
||||||
|
recency=recency if recency is not None else time.time(),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
entry = (*item.key(), next(self._counter), item)
|
||||||
|
heapq.heappush(self._entries, entry)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def pop(self) -> Request | None:
|
||||||
|
if not self._entries:
|
||||||
|
return None
|
||||||
|
return heapq.heappop(self._entries)[-1].request
|
||||||
|
|
||||||
|
def peek(self) -> Request | None:
|
||||||
|
if not self._entries:
|
||||||
|
return None
|
||||||
|
return self._entries[0][-1].request
|
||||||
|
|
||||||
|
def age(self, dt: float = 1.0) -> None:
|
||||||
|
"""Pull queued priorities toward critical so old items catch up."""
|
||||||
|
if self.age_rate <= 0:
|
||||||
|
return
|
||||||
|
rebuilt = []
|
||||||
|
for entry in self._entries:
|
||||||
|
item = entry[-1]
|
||||||
|
item.weight += self.age_rate * dt * (1.0 - item.weight)
|
||||||
|
rebuilt.append((*item.key(), next(self._counter), item))
|
||||||
|
self._entries = rebuilt
|
||||||
|
heapq.heapify(self._entries)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._entries)
|
||||||
|
|
||||||
|
def items(self) -> Iterator[tuple[float, Request]]:
|
||||||
|
for entry in sorted(self._entries, key=lambda e: e[:-1]):
|
||||||
|
item = entry[-1]
|
||||||
|
yield item.weight, item.request
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""The scheduler: gate, choice, score, queue, dispatch.
|
||||||
|
|
||||||
|
Every SemIf decision (gate, choice, score, navigation, prediction) is logged.
|
||||||
|
A high-priority input can preempt the current process, which requeues with its
|
||||||
|
state preserved; a deferred input is scored and queued by urgency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .decisions import DecisionRequest, Option, Request
|
||||||
|
from .engine import SemIfEngine
|
||||||
|
from .llm import LLMClient
|
||||||
|
from .log import DecisionLog
|
||||||
|
from .queue import UrgencyQueue
|
||||||
|
from .skill import SkillRunner
|
||||||
|
from .skills import (
|
||||||
|
ActionContext,
|
||||||
|
CreateSkill,
|
||||||
|
Skill,
|
||||||
|
build_skills,
|
||||||
|
build_tree,
|
||||||
|
compose_state,
|
||||||
|
navigate,
|
||||||
|
)
|
||||||
|
|
||||||
|
GATE_YES = "yes"
|
||||||
|
CHOICE_INTERRUPT = "interrupt"
|
||||||
|
URGENCY_OPTIONS = [
|
||||||
|
("critical", "Immediate danger or critical failure."),
|
||||||
|
("high", "Important but not dangerous."),
|
||||||
|
("medium", "Should be handled reasonably soon."),
|
||||||
|
("low", "Can wait."),
|
||||||
|
]
|
||||||
|
URGENCY_WEIGHTS = {"critical": 1.0, "high": 0.75, "medium": 0.5, "low": 0.25}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Process:
|
||||||
|
request: Request
|
||||||
|
skill: str
|
||||||
|
weight: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DispatchResult:
|
||||||
|
kind: str # ran | create_skill | error
|
||||||
|
summary: str
|
||||||
|
skill: str | None = None
|
||||||
|
decisions_logged: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Scheduler:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
engine: SemIfEngine,
|
||||||
|
llm: LLMClient,
|
||||||
|
log: DecisionLog,
|
||||||
|
config: dict,
|
||||||
|
tau: float = 0.6,
|
||||||
|
max_reentries: int = 3,
|
||||||
|
):
|
||||||
|
self.engine = engine
|
||||||
|
self.llm = llm
|
||||||
|
self.log = log
|
||||||
|
self.config = config
|
||||||
|
self.tau = tau
|
||||||
|
self.max_reentries = max_reentries
|
||||||
|
self.queue = UrgencyQueue(
|
||||||
|
max_size=int(config.get("queue", {}).get("max_size", 100)),
|
||||||
|
age_rate=float(config.get("queue", {}).get("age_rate", 0.0)),
|
||||||
|
)
|
||||||
|
self.skills = build_skills(config)
|
||||||
|
self.tree = build_tree(self.skills)
|
||||||
|
self.ctx = ActionContext(engine=self.engine, config=config)
|
||||||
|
self.runner = SkillRunner(self.ctx, self.llm, self.log)
|
||||||
|
self.current: Process | None = None
|
||||||
|
|
||||||
|
# ---- decision templates (all real SemIf, all logged) ----
|
||||||
|
|
||||||
|
def _contains_request(self, request: Request) -> bool:
|
||||||
|
decision = DecisionRequest(
|
||||||
|
state=compose_state(request),
|
||||||
|
question="Does this input contain an actionable request?",
|
||||||
|
options=[Option(GATE_YES, "Yes, it is actionable."), Option("no", "No, it is not.")],
|
||||||
|
)
|
||||||
|
result = self.engine.call(decision)
|
||||||
|
self.log.append(decision, result, extra={"phase": "gate"})
|
||||||
|
return result.prob(GATE_YES) >= self.tau
|
||||||
|
|
||||||
|
def _choice(self, request: Request, current: Process) -> bool:
|
||||||
|
decision = DecisionRequest(
|
||||||
|
state=compose_state(request, current=current.skill),
|
||||||
|
question="Should this be allowed to interrupt the current process?",
|
||||||
|
options=[
|
||||||
|
Option(CHOICE_INTERRUPT, "Yes, interrupt the current process."),
|
||||||
|
Option("defer", "No, wait until the current process finishes."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = self.engine.call(decision)
|
||||||
|
self.log.append(decision, result, extra={"phase": "choice", "current": current.skill})
|
||||||
|
return result.prob(CHOICE_INTERRUPT) >= self.tau
|
||||||
|
|
||||||
|
def _score(self, request: Request, current: str | None = None) -> tuple[float, str]:
|
||||||
|
decision = DecisionRequest(
|
||||||
|
state=compose_state(request, current=current),
|
||||||
|
question="How urgent is this request?",
|
||||||
|
options=[Option(option_id, description) for option_id, description in URGENCY_OPTIONS],
|
||||||
|
)
|
||||||
|
result = self.engine.call(decision)
|
||||||
|
self.log.append(decision, result, extra={"phase": "score"})
|
||||||
|
label = result.selected
|
||||||
|
return URGENCY_WEIGHTS[label], label
|
||||||
|
|
||||||
|
# ---- intake ----
|
||||||
|
|
||||||
|
def submit(self, text: str, source: str = "typed") -> tuple[str, str]:
|
||||||
|
"""Feed one input. Returns (status, detail)."""
|
||||||
|
from .engine import EngineUnavailable
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._submit(text, source)
|
||||||
|
except EngineUnavailable as exc:
|
||||||
|
return "error", f"decision engine unavailable: {exc}"
|
||||||
|
|
||||||
|
def _submit(self, text: str, source: str = "typed") -> tuple[str, str]:
|
||||||
|
request = Request(text, source=source)
|
||||||
|
if not self._contains_request(request):
|
||||||
|
return "dropped", "no actionable request"
|
||||||
|
|
||||||
|
if self.current is None:
|
||||||
|
weight, label = self._score(request)
|
||||||
|
self.current = Process(request=request, skill="(scheduling)", weight=weight)
|
||||||
|
outcome = self._dispatch(request)
|
||||||
|
self.current = None
|
||||||
|
return "running", f"[{label}] {outcome.summary}"
|
||||||
|
|
||||||
|
interrupt = self._choice(request, self.current)
|
||||||
|
if interrupt:
|
||||||
|
previous = self.current
|
||||||
|
previous.request.resume["from_skill"] = previous.skill
|
||||||
|
self.queue.push(previous.request, previous.weight)
|
||||||
|
self.current = Process(request=request, skill="(scheduling)", weight=1.0)
|
||||||
|
outcome = self._dispatch(request)
|
||||||
|
self.current = None
|
||||||
|
return "preempted", f"interrupted {previous.skill}; {outcome.summary}"
|
||||||
|
|
||||||
|
weight, label = self._score(request, current=self.current.skill)
|
||||||
|
ok = self.queue.push(request, weight)
|
||||||
|
if not ok:
|
||||||
|
return "rejected", "queue is full"
|
||||||
|
return "queued", f"urgency {label} (weight {weight:.2f})"
|
||||||
|
|
||||||
|
def busy(self, text: str, skill: str = "(driving)") -> None:
|
||||||
|
"""Set a fake in-progress process so the choice/score path is exercised."""
|
||||||
|
self.current = Process(request=Request(text, source="busy"), skill=skill, weight=1.0)
|
||||||
|
|
||||||
|
def idle(self) -> None:
|
||||||
|
self.current = None
|
||||||
|
|
||||||
|
def run_queue(self) -> list[tuple[str, str]]:
|
||||||
|
"""Process the queue while idle. Returns the outcomes."""
|
||||||
|
from .engine import EngineUnavailable
|
||||||
|
|
||||||
|
results = []
|
||||||
|
while self.current is None and len(self.queue) > 0:
|
||||||
|
request = self.queue.pop()
|
||||||
|
self.current = Process(request=request, skill="(scheduling)", weight=0.0)
|
||||||
|
try:
|
||||||
|
outcome = self._dispatch(request)
|
||||||
|
except EngineUnavailable as exc:
|
||||||
|
outcome = DispatchResult(kind="error", summary=f"engine unavailable: {exc}")
|
||||||
|
self.current = None
|
||||||
|
results.append(("ran", f"[{request.id}] {outcome.summary}"))
|
||||||
|
return results
|
||||||
|
|
||||||
|
# ---- dispatch ----
|
||||||
|
|
||||||
|
def _dispatch(self, request: Request) -> DispatchResult:
|
||||||
|
navigation = navigate(self.engine, request, self.tree)
|
||||||
|
if isinstance(navigation, CreateSkill):
|
||||||
|
return DispatchResult(
|
||||||
|
kind="create_skill",
|
||||||
|
summary="skill authoring via opencode is deferred to v2; request logged.",
|
||||||
|
)
|
||||||
|
outcome = self.runner.run(navigation, request)
|
||||||
|
if outcome.error:
|
||||||
|
return DispatchResult(kind="error", summary=f"skill error: {outcome.error}")
|
||||||
|
if outcome.updated_request and request.reentries < self.max_reentries:
|
||||||
|
self.queue.push(_requeue(request, outcome.updated_request), 0.5)
|
||||||
|
return DispatchResult(
|
||||||
|
kind="ran",
|
||||||
|
summary=f"{navigation.name}: {'ok' if outcome.success else 'failed'} — {outcome.summary}",
|
||||||
|
skill=navigation.name,
|
||||||
|
decisions_logged=outcome.decisions_logged,
|
||||||
|
)
|
||||||
|
|
||||||
|
def status(self) -> str:
|
||||||
|
lines = []
|
||||||
|
current = f"{self.current.skill} ({self.current.request.id})" if self.current else "idle"
|
||||||
|
lines.append(f"current: {current}")
|
||||||
|
lines.append(f"queue: {len(self.queue)} pending")
|
||||||
|
for weight, request in self.queue.items():
|
||||||
|
lines.append(f" {request.id} w={weight:.2f} {request.text[:60]}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _requeue(request: Request, updated_text: str) -> Request:
|
||||||
|
updated = Request(updated_text, source="requeue")
|
||||||
|
updated.reentries = request.reentries + 1
|
||||||
|
return updated
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""The skill execution loop: observe -> predict -> act -> observe -> assess.
|
||||||
|
|
||||||
|
Every SemIf decision made during a run is logged as a training row; the
|
||||||
|
assessment outcome is kept on the row so the dream pass can weigh failed runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .decisions import Request
|
||||||
|
from .llm import Assessment, LLMClient
|
||||||
|
from .log import DecisionLog
|
||||||
|
from .skills import ActionContext, Prediction, Skill
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunResult:
|
||||||
|
skill: str
|
||||||
|
success: bool
|
||||||
|
summary: str
|
||||||
|
action_log: str
|
||||||
|
new_state: str
|
||||||
|
updated_request: str | None = None
|
||||||
|
decisions_logged: int = 0
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRunner:
|
||||||
|
def __init__(self, ctx: ActionContext, llm: LLMClient, log: DecisionLog):
|
||||||
|
self.ctx = ctx
|
||||||
|
self.llm = llm
|
||||||
|
self.log = log
|
||||||
|
|
||||||
|
def run(self, skill: Skill, request: Request) -> RunResult:
|
||||||
|
baseline = request.text
|
||||||
|
try:
|
||||||
|
prediction = skill.predict(self.ctx, request) if skill.predict else Prediction(text="")
|
||||||
|
action = skill.act(self.ctx, request, prediction)
|
||||||
|
observed = action.new_state
|
||||||
|
assessment: Assessment = self.llm.assess(skill.name, baseline, action.action_log)
|
||||||
|
except Exception as exc:
|
||||||
|
return RunResult(
|
||||||
|
skill=skill.name,
|
||||||
|
success=False,
|
||||||
|
summary="",
|
||||||
|
action_log="",
|
||||||
|
new_state=baseline,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
run_ok = assessment.success
|
||||||
|
decisions = getattr(prediction, "decisions", [])
|
||||||
|
for decision, result in decisions:
|
||||||
|
self.log.append(decision, result, extra={"skill": skill.name, "run_ok": run_ok})
|
||||||
|
|
||||||
|
return RunResult(
|
||||||
|
skill=skill.name,
|
||||||
|
success=assessment.success,
|
||||||
|
summary=assessment.summary,
|
||||||
|
action_log=action.action_log,
|
||||||
|
new_state=observed,
|
||||||
|
updated_request=assessment.updated_request,
|
||||||
|
decisions_logged=len(decisions),
|
||||||
|
)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""The skill tree, registry, and SemIf-driven navigation.
|
||||||
|
|
||||||
|
A skill is a leaf reached by a chain of SemIf choices (category -> skill).
|
||||||
|
At every level a "create_skill" branch exists; opencode is the authoring tool
|
||||||
|
there (deferred to v2, stubbed as CreateSkill).
|
||||||
|
|
||||||
|
Only the real skills live here; navigation uses the real decision engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .decisions import DecisionRequest, Option, Request
|
||||||
|
from .engine import SemIfEngine
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionResult:
|
||||||
|
action_log: str
|
||||||
|
new_state: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Prediction:
|
||||||
|
"""The predict phase: a forecast plus any SemIf decisions it made."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
decisions: list[tuple[DecisionRequest, object]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionContext:
|
||||||
|
engine: SemIfEngine
|
||||||
|
config: dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Skill:
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
description: str
|
||||||
|
cost_budget: float = 1.0
|
||||||
|
predict: Callable[[ActionContext, Request], Prediction] = field(
|
||||||
|
default=lambda ctx, req: Prediction(text="")
|
||||||
|
)
|
||||||
|
act: Callable[[ActionContext, Request, Prediction], ActionResult] = field(
|
||||||
|
default=lambda ctx, req, pred: ActionResult("", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CreateSkill:
|
||||||
|
"""Sentinel for the 'create a missing skill' branch at a tree level."""
|
||||||
|
|
||||||
|
category: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def compose_state(request: Request, current: str | None = None) -> str:
|
||||||
|
parts = [request.text]
|
||||||
|
if current:
|
||||||
|
parts.append(f"[current process: {current}]")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _contacts(ctx: ActionContext) -> list[dict]:
|
||||||
|
path = Path(ctx.config.get("contacts", "data/contacts.json"))
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def _email_predict(ctx: ActionContext, request: Request) -> Prediction:
|
||||||
|
contacts = _contacts(ctx)
|
||||||
|
if not contacts:
|
||||||
|
return Prediction(text="no contacts available", decisions=[])
|
||||||
|
decision = DecisionRequest(
|
||||||
|
state=compose_state(request),
|
||||||
|
question="Which contact is the intended recipient?",
|
||||||
|
options=[Option(c["name"], c.get("description", "")) for c in contacts]
|
||||||
|
+ [Option("none", "None of the listed contacts.")],
|
||||||
|
)
|
||||||
|
result = ctx.engine.call(decision)
|
||||||
|
return Prediction(text=f"recipient is {result.selected}", decisions=[(decision, result)])
|
||||||
|
|
||||||
|
|
||||||
|
def _email_compose(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult:
|
||||||
|
recipient = prediction.text.removeprefix("recipient is ")
|
||||||
|
if recipient == "no contacts available" or recipient == "none":
|
||||||
|
return ActionResult(
|
||||||
|
action_log="email.compose aborted: recipient not resolved.",
|
||||||
|
new_state=request.text,
|
||||||
|
)
|
||||||
|
drafts = Path(ctx.config.get("drafts", "data/drafts"))
|
||||||
|
drafts.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = drafts / f"{request.id}.txt"
|
||||||
|
target.write_text(f"To: {recipient}\nBody: {request.text}\n")
|
||||||
|
return ActionResult(
|
||||||
|
action_log=f"email.compose: wrote draft {target} for {recipient!r}.",
|
||||||
|
new_state=f"Draft written to {target.name} for {recipient}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _response_reject(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult:
|
||||||
|
message = f"Rejected: I cannot act on this while busy ({request.text})."
|
||||||
|
return ActionResult(action_log=f"response.reject: {message}", new_state=message)
|
||||||
|
|
||||||
|
|
||||||
|
def _tracking_check(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult:
|
||||||
|
path = Path(ctx.config.get("packages", "data/packages.json"))
|
||||||
|
if not path.is_file():
|
||||||
|
return ActionResult(
|
||||||
|
action_log="tracking.check aborted: no packages file.",
|
||||||
|
new_state=request.text,
|
||||||
|
)
|
||||||
|
packages = json.loads(path.read_text())
|
||||||
|
lines = [f"{p.get('id')}: {p.get('status')}" for p in packages]
|
||||||
|
report = "Tracking statuses:\n" + "\n".join(lines)
|
||||||
|
return ActionResult(action_log="tracking.check: " + report, new_state=report)
|
||||||
|
|
||||||
|
|
||||||
|
def build_skills(config: dict) -> list[Skill]:
|
||||||
|
skills = config.get("skills", {})
|
||||||
|
return [
|
||||||
|
Skill(
|
||||||
|
name="email.compose",
|
||||||
|
category="email",
|
||||||
|
description="Compose and dispatch an email.",
|
||||||
|
predict=_email_predict,
|
||||||
|
act=_email_compose,
|
||||||
|
cost_budget=float(skills.get("email", {}).get("cost_budget", 1.0)),
|
||||||
|
),
|
||||||
|
Skill(
|
||||||
|
name="response.reject",
|
||||||
|
category="response",
|
||||||
|
description="Politely reject a request because the agent is busy.",
|
||||||
|
act=_response_reject,
|
||||||
|
),
|
||||||
|
Skill(
|
||||||
|
name="tracking.check",
|
||||||
|
category="tracking",
|
||||||
|
description="Check the delivery status of a package.",
|
||||||
|
act=_tracking_check,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_tree(skills: list[Skill]) -> dict[str, list[Skill]]:
|
||||||
|
tree: dict[str, list[Skill]] = {}
|
||||||
|
for skill in skills:
|
||||||
|
tree.setdefault(skill.category, []).append(skill)
|
||||||
|
for category in tree:
|
||||||
|
tree[category].sort(key=lambda s: s.name)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
def navigate(engine: SemIfEngine, request: Request, tree: dict[str, list[Skill]]) -> Skill | CreateSkill:
|
||||||
|
"""Descend the tree one SemIf choice per level."""
|
||||||
|
categories = sorted(tree.keys())
|
||||||
|
create = Option("create_skill", "Create a new skill for this.")
|
||||||
|
top = DecisionRequest(
|
||||||
|
state=compose_state(request),
|
||||||
|
question="Which top-level category handles this request?",
|
||||||
|
options=[Option(c, c) for c in categories] + [create],
|
||||||
|
)
|
||||||
|
top_result = engine.call(top)
|
||||||
|
category = top_result.selected
|
||||||
|
if category == "create_skill":
|
||||||
|
return CreateSkill(category=None)
|
||||||
|
skills = tree[category]
|
||||||
|
leaf = DecisionRequest(
|
||||||
|
state=compose_state(request, current=category),
|
||||||
|
question=f"Within {category}, which skill?",
|
||||||
|
options=[Option(s.name, s.description) for s in skills] + [create],
|
||||||
|
)
|
||||||
|
leaf_result = engine.call(leaf)
|
||||||
|
pick = leaf_result.selected
|
||||||
|
if pick == "create_skill":
|
||||||
|
return CreateSkill(category=category)
|
||||||
|
return next(s for s in skills if s.name == pick)
|
||||||
|
|
||||||
|
|
||||||
|
def tree_summary(tree: dict[str, list[Skill]]) -> str:
|
||||||
|
lines = []
|
||||||
|
for category in sorted(tree):
|
||||||
|
names = ", ".join(s.name for s in tree[category])
|
||||||
|
lines.append(f" {category}: {names}")
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""End-to-end pipeline test. Run ONLY on the box with real SemIf + real LLM.
|
||||||
|
|
||||||
|
python -m pytest tests/integration -q
|
||||||
|
|
||||||
|
The decision engine is real (llamacpp GGUF) and self-assessment is a real
|
||||||
|
local LLM endpoint. If either is unavailable this fails loudly — no mocking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from semif_agent.cli import build_scheduler, load_config
|
||||||
|
from semif_agent.dream import dream
|
||||||
|
from semif_agent.engine import EngineUnavailable
|
||||||
|
|
||||||
|
|
||||||
|
def require_real(config: dict):
|
||||||
|
from semif_agent.engine import SemIfEngine, EngineConfig
|
||||||
|
|
||||||
|
engine = SemIfEngine(
|
||||||
|
EngineConfig(
|
||||||
|
backend=config.get("engine", {}).get("backend", "llamacpp"),
|
||||||
|
source=config.get("engine", {}).get("source", ""),
|
||||||
|
revision=config.get("engine", {}).get("revision", ""),
|
||||||
|
gguf=config.get("engine", {}).get("gguf", ""),
|
||||||
|
context_tokens=int(config.get("engine", {}).get("context_tokens", 4096)),
|
||||||
|
threads=config.get("engine", {}).get("threads"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
engine._ensure_loaded()
|
||||||
|
except EngineUnavailable as exc:
|
||||||
|
pytest.fail(f"real engine unavailable: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_end_to_end(tmp_path):
|
||||||
|
config = load_config()
|
||||||
|
require_real(config)
|
||||||
|
config["log"] = str(tmp_path / "decisions.jsonl")
|
||||||
|
scheduler, config = build_scheduler(config)
|
||||||
|
|
||||||
|
inputs = [
|
||||||
|
"send my girlfriend an email that says I'm going to be late to the party",
|
||||||
|
"tell me if my package was delivered",
|
||||||
|
]
|
||||||
|
for text in inputs:
|
||||||
|
status, detail = scheduler.submit(text)
|
||||||
|
print(f"[{status}] {detail}")
|
||||||
|
assert status in ("running", "preempted", "queued", "rejected")
|
||||||
|
|
||||||
|
scheduler.run_queue()
|
||||||
|
|
||||||
|
rows = scheduler.log.read()
|
||||||
|
assert len(rows) > 0, "expected SemIf decisions to be logged"
|
||||||
|
|
||||||
|
report = dream(scheduler.log)
|
||||||
|
assert report.cross_entropy is not None
|
||||||
|
print(report.render())
|
||||||
|
|
||||||
|
skills = scheduler.status()
|
||||||
|
assert "queue:" in skills
|
||||||
|
|
||||||
|
contacts_path = Path(config.get("skills", {}).get("contacts", "data/contacts.json"))
|
||||||
|
assert contacts_path.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_busy_choice_path(tmp_path):
|
||||||
|
config = load_config()
|
||||||
|
require_real(config)
|
||||||
|
config["log"] = str(tmp_path / "decisions.jsonl")
|
||||||
|
scheduler, config = build_scheduler(config)
|
||||||
|
|
||||||
|
scheduler.busy("driving on the freeway", skill="driving")
|
||||||
|
status, detail = scheduler.submit("send my girlfriend an email that says I'm going to be late")
|
||||||
|
print(f"[{status}] {detail}")
|
||||||
|
assert status in ("preempted", "queued", "dropped")
|
||||||
|
scheduler.idle()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from semif_agent.decisions import DecisionRequest, DecisionResult, Option, Request
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_semif_row_shape():
|
||||||
|
request = DecisionRequest(
|
||||||
|
state="some state",
|
||||||
|
question="some question?",
|
||||||
|
options=[Option("a", "A."), Option("b", "B.")],
|
||||||
|
id="abc",
|
||||||
|
)
|
||||||
|
row = request.to_semif_row()
|
||||||
|
assert row["id"] == "abc"
|
||||||
|
assert row["state"] == "some state"
|
||||||
|
assert row["question"] == "some question?"
|
||||||
|
assert row["options"] == [
|
||||||
|
{"id": "a", "description": "A."},
|
||||||
|
{"id": "b", "description": "B."},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_selected_and_probs():
|
||||||
|
request = DecisionRequest(
|
||||||
|
state="s",
|
||||||
|
question="q",
|
||||||
|
options=[Option("a", "A."), Option("b", "B.")],
|
||||||
|
)
|
||||||
|
result = DecisionResult(
|
||||||
|
request=request, option_ids=["a", "b"], probabilities=[0.3, 0.7]
|
||||||
|
)
|
||||||
|
assert result.selected == "b"
|
||||||
|
assert result.probs == {"a": 0.3, "b": 0.7}
|
||||||
|
assert result.prob("a") == 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_requeue_preserves_state():
|
||||||
|
original = Request(text="t", priority=0.7)
|
||||||
|
original.resume["from_skill"] = "email.compose"
|
||||||
|
updated = original.copy_for_requeue()
|
||||||
|
assert updated.id == original.id
|
||||||
|
assert updated.priority == original.priority
|
||||||
|
assert updated.resume["from_skill"] == "email.compose"
|
||||||
|
assert updated.reentries == original.reentries + 1
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from semif_agent.decisions import DecisionRequest, DecisionResult, Option
|
||||||
|
from semif_agent.dream import HUMAN_WEIGHT, SELF_WEIGHT, dream
|
||||||
|
from semif_agent.log import DecisionLog
|
||||||
|
|
||||||
|
|
||||||
|
def make_result(probs):
|
||||||
|
request = DecisionRequest(
|
||||||
|
state="state",
|
||||||
|
question="question",
|
||||||
|
options=[Option(key, key) for key in probs],
|
||||||
|
)
|
||||||
|
result = DecisionResult(
|
||||||
|
request=request,
|
||||||
|
option_ids=list(probs),
|
||||||
|
probabilities=list(probs.values()),
|
||||||
|
)
|
||||||
|
return request, result
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_entropy_matches_hand_calculation(tmp_path):
|
||||||
|
log = DecisionLog(str(tmp_path / "decisions.jsonl"))
|
||||||
|
probs = {"yes": 0.8, "no": 0.2}
|
||||||
|
request, result = make_result(probs)
|
||||||
|
log.append(request, result, label="no")
|
||||||
|
report = dream(log)
|
||||||
|
assert report.cross_entropy == pytest.approx(-math.log(0.2))
|
||||||
|
assert report.rows[0].nll == pytest.approx(-math.log(0.2))
|
||||||
|
assert report.rows[0].weight == HUMAN_WEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_label_is_selected(tmp_path):
|
||||||
|
log = DecisionLog(str(tmp_path / "decisions.jsonl"))
|
||||||
|
request, result = make_result({"yes": 0.9, "no": 0.1})
|
||||||
|
log.append(request, result)
|
||||||
|
report = dream(log)
|
||||||
|
row = report.rows[0]
|
||||||
|
assert row.observed == "yes"
|
||||||
|
assert row.correct is True
|
||||||
|
assert row.weight == SELF_WEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def test_accuracy_and_ece(tmp_path):
|
||||||
|
log = DecisionLog(str(tmp_path / "decisions.jsonl"))
|
||||||
|
for probs, label in [
|
||||||
|
({"yes": 0.9, "no": 0.1}, "yes"),
|
||||||
|
({"yes": 0.6, "no": 0.4}, "no"),
|
||||||
|
({"yes": 0.9, "no": 0.1}, "yes"),
|
||||||
|
]:
|
||||||
|
request, result = make_result(probs)
|
||||||
|
log.append(request, result, label=label)
|
||||||
|
report = dream(log)
|
||||||
|
assert report.accuracy == pytest.approx(2 / 3)
|
||||||
|
assert report.ece is not None and 0.0 <= report.ece <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_relabel_human_override(tmp_path):
|
||||||
|
log = DecisionLog(str(tmp_path / "decisions.jsonl"))
|
||||||
|
request, result = make_result({"yes": 0.9, "no": 0.1})
|
||||||
|
log.append(request, result)
|
||||||
|
assert log.relabel(request.id, "no") is True
|
||||||
|
rows = log.read()
|
||||||
|
assert rows[0]["observed_outcome"] == "no"
|
||||||
|
assert rows[0]["label_source"] == "human"
|
||||||
|
assert log.relabel("missing", "yes") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_unlabeled(tmp_path):
|
||||||
|
log = DecisionLog(str(tmp_path / "decisions.jsonl"))
|
||||||
|
log.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log.path.write_text('{"id": "x", "predicted_probs": {"a": 1.0}}\n')
|
||||||
|
report = dream(log)
|
||||||
|
assert report.skipped == 1
|
||||||
|
assert report.rows == []
|
||||||
|
assert report.cross_entropy is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamped_probability_never_zero(tmp_path):
|
||||||
|
log = DecisionLog(str(tmp_path / "decisions.jsonl"))
|
||||||
|
log.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log.path.write_text(
|
||||||
|
'{"id": "x", "predicted_probs": {"a": 0.0, "b": 1.0}, '
|
||||||
|
'"selected": "b", "observed_outcome": "a", "label_source": "human"}\n'
|
||||||
|
)
|
||||||
|
report = dream(log)
|
||||||
|
assert report.rows[0].nll == pytest.approx(-math.log(1e-9))
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from semif_agent.queue import UrgencyQueue
|
||||||
|
from semif_agent.decisions import Request
|
||||||
|
|
||||||
|
|
||||||
|
def make(text, weight):
|
||||||
|
return Request(text=text), weight
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_by_weight_desc():
|
||||||
|
q = UrgencyQueue()
|
||||||
|
req_a, _ = make("low", 0.25)
|
||||||
|
req_c, _ = make("critical", 1.0)
|
||||||
|
req_h, _ = make("high", 0.75)
|
||||||
|
q.push(req_a, 0.25)
|
||||||
|
q.push(req_c, 1.0)
|
||||||
|
q.push(req_h, 0.75)
|
||||||
|
assert q.pop().text == "critical"
|
||||||
|
assert q.pop().text == "high"
|
||||||
|
assert q.pop().text == "low"
|
||||||
|
assert q.pop() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fifo_tiebreak():
|
||||||
|
q = UrgencyQueue()
|
||||||
|
req_a, _ = make("first", 0.5)
|
||||||
|
req_b, _ = make("second", 0.5)
|
||||||
|
q.push(req_a, 0.5)
|
||||||
|
q.push(req_b, 0.5)
|
||||||
|
assert q.pop().text == "first"
|
||||||
|
assert q.pop().text == "second"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fifo_beats_recency():
|
||||||
|
q = UrgencyQueue()
|
||||||
|
old, _ = make("older", 0.5)
|
||||||
|
new, _ = make("newer", 0.5)
|
||||||
|
q.push(old, 0.5, recency=1.0)
|
||||||
|
q.push(new, 0.5, recency=2.0)
|
||||||
|
assert q.pop().text == "older"
|
||||||
|
|
||||||
|
|
||||||
|
def test_peek_does_not_remove():
|
||||||
|
q = UrgencyQueue()
|
||||||
|
req, _ = make("peek", 0.9)
|
||||||
|
q.push(req, 0.9)
|
||||||
|
assert q.peek().text == "peek"
|
||||||
|
assert len(q) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounds_reject():
|
||||||
|
q = UrgencyQueue(max_size=2)
|
||||||
|
assert q.push(make("a", 1.0)[0], 1.0)
|
||||||
|
assert q.push(make("b", 1.0)[0], 1.0)
|
||||||
|
assert not q.push(make("c", 1.0)[0], 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_raises_priority():
|
||||||
|
q = UrgencyQueue(age_rate=1.0)
|
||||||
|
old, _ = make("aging", 0.2)
|
||||||
|
new, _ = make("busy", 1.0)
|
||||||
|
q.push(old, 0.2)
|
||||||
|
q.push(new, 1.0)
|
||||||
|
q.age(dt=10.0)
|
||||||
|
assert q.peek().text == "aging"
|
||||||
|
|
||||||
|
|
||||||
|
def test_items_sorted():
|
||||||
|
q = UrgencyQueue()
|
||||||
|
q.push(make("b", 0.5)[0], 0.5)
|
||||||
|
q.push(make("a", 1.0)[0], 1.0)
|
||||||
|
weights = [weight for weight, _ in q.items()]
|
||||||
|
assert weights == [1.0, 0.5]
|
||||||
Reference in New Issue
Block a user