Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 34 additions & 9 deletions src/spark_character/codex_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
def _default_codex_binary() -> str:
explicit = os.environ.get("CODEX_PATH") or os.environ.get("SPARK_CODEX_PATH")
if explicit:
return explicit
# Validate the path exists and is a regular file to prevent
# arbitrary binary execution via malicious env vars
expanded = os.path.expanduser(explicit)
if not os.path.isfile(expanded):
raise FileNotFoundError(f"Codex binary not found: {expanded}")
return expanded
if sys.platform.startswith("win"):
return "codex.cmd"
return "codex"
Expand Down Expand Up @@ -75,15 +80,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 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")))
2 changes: 1 addition & 1 deletion src/spark_character/prompt_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
re.compile(PROMPT_BOUNDARY_PREFIX + r"(system|developer)\s+(prompt|message|instruction)s?\b.*\b(override|replace|ignore)\b", re.I),
),
("hidden-html", re.compile(r"<!--|<\s*(?:div|span)[^>]*(?:display\s*:\s*none|visibility\s*:\s*hidden)", re.I)),
("secret-exfiltration", re.compile(r"\b(curl|wget|fetch)\b.*\b(\.env|secret|token|api[_-]?key|password)\b", re.I)),
("secret-exfiltration", re.compile(r"\b(curl|wget|fetch)\b.*(?<!\w)(\.env|secret|token|api[_-]?key|password)\b", re.I)),
("secret-file-request", re.compile(r"\b(read|open|print|cat|get-content)\b.*(\.env|secrets\.local\.json|id_rsa|\.ssh|api[_-]?key)\b", re.I)),
("private-key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.I)),
)
Expand Down
2 changes: 2 additions & 0 deletions src/spark_character/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ def promote_evolved_persona_to_chip_lab(
try:
base_spec = validate_chip_yaml_spec(yaml.safe_load(base_yaml_path.read_text(encoding="utf-8")) or {})
except (OSError, ValueError, yaml.YAMLError) as exc:
# Keep the basename (not the full path) so the operator knows which
# chip file failed without leaking the surrounding directory layout.
raise ValueError(
f"Base personality chip YAML is invalid: {base_yaml_path.name}"
) from exc
Expand Down
Loading
Loading