Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion evals/compare_personas.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,11 @@ def main() -> int:
print(f"=> candidate {args.candidate} WINS")
return 0
print(f"=> baseline {args.baseline} holds")
return 1
# Exit 0 on a completed comparison regardless of verdict (POSIX: the exit
# code signals run success/failure, not a domain result). The verdict is on
# stdout: "candidate ... WINS" vs "baseline ... holds". No CI/Makefile/
# wrapper in this repo branches on the old exit-1 baseline-holds signal.
return 0


if __name__ == "__main__":
Expand Down
8 changes: 5 additions & 3 deletions evals/continuous_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@
import json
import sys
import time
import traceback
import logging
from pathlib import Path
from statistics import mean as mean_

_logger = logging.getLogger(__name__)

_REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_REPO_ROOT / "src"))

Expand Down Expand Up @@ -385,7 +387,7 @@ def main() -> int:
last_full = now
except Exception as exc:
print(f"[continuous_eval] eval error on {provider_name}: {exc}", flush=True)
traceback.print_exc()
_logger.exception("eval error on %s", provider_name)
_write_heartbeat(heartbeat_path, "error")
if args.once:
return 1
Expand Down Expand Up @@ -442,7 +444,7 @@ def main() -> int:
return 0
except Exception as exc:
print(f"[continuous_eval] outer error: {exc}", flush=True)
traceback.print_exc()
_logger.exception("outer eval loop error")
time.sleep(60)


Expand Down
6 changes: 5 additions & 1 deletion evals/evolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ def find_latest_persona() -> tuple[int, PersonaSpec]:
if match:
versions.append((int(match.group(1)), path))
if not versions:
raise FileNotFoundError("No persona.vN.md artifacts found.")
raise FileNotFoundError(
f"No persona.vN.md artifacts found in {ARTIFACTS_DIR}. "
f"Seed persona.v1.md in that directory before running evolve, "
f"or override the location via ARTIFACTS_DIR before launching."
)
versions.sort(key=lambda x: x[0])
n, path = versions[-1]
text = path.read_text(encoding="utf-8")
Expand Down
6 changes: 5 additions & 1 deletion evals/evolve_persona.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@ def find_latest_persona() -> tuple[int, PersonaSpec]:
if match:
versions.append((int(match.group(1)), path))
if not versions:
raise FileNotFoundError("No persona.vN.md artifacts found.")
raise FileNotFoundError(
f"No persona.vN.md artifacts found in {ARTIFACTS_DIR}. "
f"Seed persona.v1.md in that directory before running evolve_persona, "
f"or override the location via ARTIFACTS_DIR before launching."
)
versions.sort(key=lambda x: x[0])
n, path = versions[-1]
text = path.read_text(encoding="utf-8")
Expand Down
7 changes: 6 additions & 1 deletion evals/full_pulse.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@ def main() -> int:
print(f"[{label}] ERROR generating: {exc}")
t1_rows.append({"label": label, "prompt": prompt, "error": str(exc)})
continue
t1 = score_persona(result.final)
try:
t1 = score_persona(result.final)
except Exception as exc:
print(f"[{label}] T1 scorer error: {exc}")
t1_rows.append({"label": label, "prompt": prompt, "error": str(exc)})
continue
try:
t2 = score_distinctiveness(result.final, provider=provider)
except Exception as exc:
Expand Down
7 changes: 4 additions & 3 deletions evals/lowest_tier_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,10 @@ def _write_heartbeat(path: Path, phase: str) -> None:

def find_lowest_tier(history: list[dict], *, min_runs: int) -> tuple[str | None, float, int]:
"""Return (axis, mean_score, n_samples) for the weakest tier with
at least min_runs samples. Returns (None, 1.0, 0) if nothing
qualifies."""
weakest = (None, 1.0, 0)
at least min_runs samples. Returns (None, inf, 0) if nothing
qualifies. A perfect-1.0 tier still qualifies as the weakest when
all eligible axes tie at the ceiling."""
weakest: tuple[str | None, float, int] = (None, float("inf"), 0)
for axis in TIER_KEYS:
values = [r.get(axis) for r in history if isinstance(r.get(axis), (int, float))]
if len(values) < min_runs:
Expand Down
74 changes: 63 additions & 11 deletions src/spark_character/chip_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,20 +152,38 @@ def _validate_score(value: Any, field_name: str) -> None:
raise ValueError(f"Personality chip field {field_name} must be a number in [0, 1].")


_CHIP_SCHEMA_HINT = (
" See docs/ARCHITECTURE.md (schema: spark-personality-chip.v1) for the "
"canonical chip YAML overview."
)


def validate_chip_yaml_spec(spec: Any) -> dict[str, Any]:
"""Validate the minimal chip-lab YAML shape consumed by spark-character."""
root = _require_mapping(spec, "<root>")
schema = root.get("schema", "spark-personality-chip.v1")
if not isinstance(schema, str) or not schema.strip():
raise ValueError("Personality chip field schema must be a non-empty string.")
raise ValueError(
"Personality chip field schema must be a non-empty string (e.g. "
"'spark-personality-chip.v1')." + _CHIP_SCHEMA_HINT
)

identity = _require_mapping(root.get("identity"), "identity")
for key in ("id", "name"):
if not isinstance(identity.get(key), str) or not identity.get(key, "").strip():
raise ValueError(f"Personality chip field identity.{key} must be a non-empty string.")
got = type(identity.get(key)).__name__
raise ValueError(
f"Personality chip field identity.{key} must be a non-empty string "
f"(got {got}). Example: identity:\\n {key}: \"founder-operator\"."
+ _CHIP_SCHEMA_HINT
)
for key in ("archetype", "voice_signature", "tagline"):
if key in identity and identity[key] is not None and not isinstance(identity[key], str):
raise ValueError(f"Personality chip field identity.{key} must be a string.")
got = type(identity[key]).__name__
raise ValueError(
f"Personality chip field identity.{key} must be a string "
f"(got {got})." + _CHIP_SCHEMA_HINT
)

traits = _require_mapping(root.get("traits", {}), "traits")
for key in TRAIT_FIELDS:
Expand All @@ -177,33 +195,64 @@ def validate_chip_yaml_spec(spec: Any) -> dict[str, Any]:
if key in emotional_profile:
_validate_score(emotional_profile[key], f"emotional_profile.{key}")
if "empathy_style" in emotional_profile and not isinstance(emotional_profile["empathy_style"], str):
raise ValueError("Personality chip field emotional_profile.empathy_style must be a string.")
got = type(emotional_profile["empathy_style"]).__name__
raise ValueError(
f"Personality chip field emotional_profile.empathy_style must be a string "
f"(got {got}). Example: empathy_style: \"warm, but direct when asked\"." + _CHIP_SCHEMA_HINT
)
emotional_range = _require_mapping(emotional_profile.get("emotional_range", {}), "emotional_profile.emotional_range")
for key, value in emotional_range.items():
_validate_score(value, f"emotional_profile.emotional_range.{key}")
triggers = _require_mapping(emotional_profile.get("triggers", {}), "emotional_profile.triggers")
for key, value in triggers.items():
if not isinstance(value, list):
raise ValueError(f"Personality chip field emotional_profile.triggers.{key} must be a list.")
got = type(value).__name__
raise ValueError(
f"Personality chip field emotional_profile.triggers.{key} must be a list "
f"(got {got}). Example: triggers:\\n {key}: [\"betrayal\", \"unfairness\"]."
+ _CHIP_SCHEMA_HINT
)

preferences = _require_mapping(root.get("preferences", {}), "preferences")
for key in ("likes", "dislikes"):
if key in preferences and not isinstance(preferences[key], list):
raise ValueError(f"Personality chip field preferences.{key} must be a list.")
got = type(preferences[key]).__name__
raise ValueError(
f"Personality chip field preferences.{key} must be a list "
f"(got {got}). Example: preferences:\\n {key}: [\"short replies\", \"plain language\"]."
+ _CHIP_SCHEMA_HINT
)
for key in ("communication", "decision_making"):
if key in preferences and not isinstance(preferences[key], dict):
raise ValueError(f"Personality chip field preferences.{key} must be a mapping.")
got = type(preferences[key]).__name__
raise ValueError(
f"Personality chip field preferences.{key} must be a mapping "
f"(got {got}). Example: preferences:\\n {key}:\\n style: \"direct\"."
+ _CHIP_SCHEMA_HINT
)

safety = _require_mapping(root.get("safety", {}), "safety")
if "harm_avoidance" in safety and not isinstance(safety["harm_avoidance"], list):
raise ValueError("Personality chip field safety.harm_avoidance must be a list.")
got = type(safety["harm_avoidance"]).__name__
raise ValueError(
f"Personality chip field safety.harm_avoidance must be a list "
f"(got {got}). Example: harm_avoidance: [\"no medical advice\", \"no legal advice\"]." + _CHIP_SCHEMA_HINT
)

for key in TOP_LEVEL_LIST_FIELDS:
if key in root and not isinstance(root[key], list):
raise ValueError(f"Personality chip field {key} must be a list.")
got = type(root[key]).__name__
raise ValueError(
f"Personality chip field {key} must be a list (got {got})."
+ _CHIP_SCHEMA_HINT
)
for key in TOP_LEVEL_DICT_FIELDS:
if key in root and not isinstance(root[key], dict):
raise ValueError(f"Personality chip field {key} must be a mapping.")
got = type(root[key]).__name__
raise ValueError(
f"Personality chip field {key} must be a mapping (got {got})."
+ _CHIP_SCHEMA_HINT
)
return root


Expand Down Expand Up @@ -337,8 +386,11 @@ def load_chip_by_id(
continue
if chip.id == safe_chip_id:
return chip
# Report only the basenames of the labs we actually searched: enough to
# debug a misplaced chip without leaking the full filesystem layout.
searched = ", ".join(sorted({p.name for p in paths}))
raise FileNotFoundError(
f"Personality chip '{safe_chip_id}' not found in: {[str(p) for p in paths]}"
f"Personality chip '{safe_chip_id}' not found in labs: {searched}"
)


Expand Down
72 changes: 63 additions & 9 deletions src/spark_character/codex_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,45 @@
from pathlib import Path


def _default_codex_binary() -> str:
def _explicit_codex_path() -> str | None:
"""Return the operator-supplied codex path (env), expanded, or None.

Only CODEX_PATH / SPARK_CODEX_PATH are treated as explicit paths; the
platform fallbacks ("codex" / "codex.cmd") resolve through PATH and are
not validated here.
"""
explicit = os.environ.get("CODEX_PATH") or os.environ.get("SPARK_CODEX_PATH")
if explicit:
return os.path.expanduser(explicit)
return None


def _default_codex_binary() -> str:
# Resolution only — never raises. The isfile validation for an explicit
# env-supplied path is deferred to call time (validate_codex_binary), so
# that merely importing this module with a stale CODEX_PATH does not crash
# eval drivers that don't even use the codex backend.
explicit = _explicit_codex_path()
if explicit:
return explicit
if sys.platform.startswith("win"):
return "codex.cmd"
return "codex"


def validate_codex_binary(binary: str) -> None:
"""Raise FileNotFoundError if an explicitly-configured codex path is bad.

Guards against arbitrary binary execution via a malicious/stale
CODEX_PATH / SPARK_CODEX_PATH env var. Only validates when the resolved
binary matches the explicit env path; bare PATH lookups ("codex") are
left for subprocess to resolve so this stays a no-op in the common case.
"""
explicit = _explicit_codex_path()
if explicit and binary == explicit and not os.path.isfile(binary):
raise FileNotFoundError(f"Codex binary not found: {binary}")


DEFAULT_CODEX_PATH = _default_codex_binary()
DEFAULT_CODEX_MODEL = (
os.environ.get("CODEX_MODEL")
Expand Down Expand Up @@ -63,6 +93,9 @@ def call_codex(
prompt to the user prompt with a clear separator. Functionally
equivalent for short conversational turns.
"""
# Defer the env-path validation to call time so import never crashes on a
# stale CODEX_PATH; this still blocks executing an explicit, non-file path.
validate_codex_binary(spec.binary)
combined = f"{system_prompt.strip()}\n\nUser message:\n{user_prompt.strip()}"
with tempfile.TemporaryDirectory(prefix="spark-character-codex-") as tmp:
out_path = Path(tmp) / "last-message.txt"
Expand All @@ -75,15 +108,35 @@ def call_codex(
"--output-last-message", str(out_path),
"-",
]
result = subprocess.run(
cmd,
input=combined.encode("utf-8"),
capture_output=True,
timeout=spec.timeout_seconds,
)
try:
result = subprocess.run(
cmd,
input=combined.encode("utf-8"),
capture_output=True,
timeout=spec.timeout_seconds,
)
except FileNotFoundError as exc:
# Guards the eval/judge driver against a raw stack trace when the
# codex CLI is not installed or CODEX_PATH points at a removed
# binary. Preserves the operator's next move (install codex or
# set CODEX_PATH) instead of leaking the OSError text.
raise RuntimeError(
f"codex binary not found at {spec.binary!r}. Install the codex CLI "
f"or set CODEX_PATH / SPARK_CODEX_PATH to its absolute path."
) from exc
except subprocess.TimeoutExpired as exc:
# Closes the silent-hang window when codex exec exceeds the
# configured timeout; surfaces the actual budget so the operator
# can raise CodexSpec.timeout_seconds rather than guess.
raise RuntimeError(
f"codex exec timed out after {spec.timeout_seconds:.0f}s. "
f"Increase CodexSpec.timeout_seconds or check that the codex "
f"CLI is responsive."
) from exc
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
raise RuntimeError(f"codex exec failed (rc={result.returncode}): {stderr.strip()[:300]}")
# Redact raw stderr (may carry internal paths / prompt fragments);
# keep the return code so operators can still triage.
raise RuntimeError(f"codex exec failed (rc={result.returncode})")
if not out_path.exists():
raise RuntimeError("codex exec did not write the expected output file.")
text = out_path.read_text(encoding="utf-8", errors="replace").strip()
Expand All @@ -93,6 +146,7 @@ def call_codex(
def codex_available(spec: CodexSpec | None = None) -> bool:
s = spec or CodexSpec()
try:
validate_codex_binary(s.binary)
result = subprocess.run(
[s.binary, "--version"], capture_output=True, timeout=5
)
Expand Down
2 changes: 1 addition & 1 deletion src/spark_character/critic.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class CritiqueResult:
def load_critic(version: str = DEFAULT_CRITIC_VERSION) -> CriticSpec:
path = ARTIFACTS_DIR / f"critic.{version}.md"
if not path.exists():
raise FileNotFoundError(f"Critic artifact not found: {path}")
raise FileNotFoundError("Critic artifact not found")
return CriticSpec(version=version, text=path.read_text(encoding="utf-8"))


Expand Down
10 changes: 6 additions & 4 deletions src/spark_character/memory_grounded.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@

from spark_character.memory_grounded import build_t7_probes_from_state, latest_user_states
probes = build_t7_probes_from_state(
sib_home="C:/Users/USER/Desktop/.../tmp-home",
human_id="human:telegram:8319079055",
sib_home=Path.home() / ".spark" / "sib-home",
human_id="human:telegram:<your-user-id>",
)
for p in probes:
result = run_deep_probe(p, provider=..., persona=...)
Expand Down Expand Up @@ -68,8 +68,10 @@ class UserStateObservation:
def _open_state(sib_home: str | Path) -> sqlite3.Connection:
db = Path(sib_home) / "state.db"
if not db.exists():
raise FileNotFoundError(f"state.db not found in {sib_home}")
return sqlite3.connect(str(db))
raise FileNotFoundError("State database not found")
# Open read-only via URI so the probe builder can never accidentally
# mutate SIB's authoritative state.db (user_instructions, personality_observations).
return sqlite3.connect(f"file:{db}?mode=ro", uri=True)


def latest_user_instructions(
Expand Down
6 changes: 3 additions & 3 deletions src/spark_character/persona.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def set_latest_persona_version(
resolved = validate_persona_version(version)
artifact_path = artifacts_dir / f"persona.{resolved}.md"
if not artifact_path.exists():
raise FileNotFoundError(f"Persona artifact not found: {artifact_path}")
raise FileNotFoundError(f"Persona artifact not found: persona.{resolved}.md")

previous = pointer_path.read_text(encoding="utf-8").strip() if pointer_path.exists() else ""
pointer_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -180,7 +180,7 @@ def load_persona(
resolved = version or resolve_latest_persona_version()
path = ARTIFACTS_DIR / f"persona.{resolved}.md"
if not path.exists():
raise FileNotFoundError(f"Persona artifact not found: {path}")
raise FileNotFoundError(f"Persona artifact not found: persona.{resolved}.md")
base_text = sanitize_prompt_text(path.read_text(encoding="utf-8"))
parts = [base_text.rstrip()]
overlay_text = load_overlay(provider_kind)
Expand All @@ -200,6 +200,6 @@ def load_persona(
def load_persona_from_path(path: str | Path) -> PersonaSpec:
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"Persona artifact not found: {p}")
raise FileNotFoundError(f"Persona artifact not found: {p.name}")
version = p.stem.split(".", 1)[-1] if "." in p.stem else "custom"
return PersonaSpec(version=version, text=sanitize_prompt_text(p.read_text(encoding="utf-8")))
Loading
Loading