Files
ik-codex/docs/superpowers/specs/2026-05-04-pz-deterministic-classifier-design.md
indifferentketchup fdf70a0c06 docs: align lookback test purpose and spec normalization list
Honest test docstring (old/new semantics equivalent on contiguous
entries; test locks post-fix behavior against future regressions),
and add severity-prefix strip to the spec's normalization list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:39:44 +00:00

14 KiB

PZ deterministic classifier — design spec

Drafted 2026-05-04. Status: design-approved, awaiting implementation plan. Sibling tool to the existing pre-production Qwen analyzer (pz_error_analysis.py), which is unaffected by this work.

Summary

A new deterministic-only Project Zomboid log classifier that lives alongside the existing Qwen-based analyzer in tools/pz-analyzer/. Walks redacted DebugLog-server*.txt files, extracts errors/warnings, attributes each to a mod where evidence allows, classifies by kind, and emits a structured JSON report. Zero AI dependency: this is the artefact that informs the future PHP / iblogs production path.

The patterns it implements are inspired by paraxaQQ/pzmm's core/inspector.py — Lua mod-marker attribution, multi-fallback file:line extraction, bidirectional stack collection, cause-chain unwinding, engine-noise tagging. Reimplemented originally; no code copied verbatim.

Why a separate tool, not an edit of pz_error_analysis.py

Two artefacts, two purposes:

  • pz_error_analysis.py (existing, untouched) — pre-production discovery tool. Sends residual log content to Qwen so the developer can see what categories the deterministic side hasn't yet captured.
  • pz_classify.py (new) — production-bound deterministic classifier. Output is what an iblogs PHP port would eventually emit. Runs in seconds, no API dependency, no PII-going-to-LLM consideration.

Coexisting them lets the developer compare outputs and treat the LLM's residual output as the "deterministic to-do list."

Scope

In scope:

  • Two new files: tools/pz-analyzer/pz_parser.py (pure module) and tools/pz-analyzer/pz_classify.py (CLI orchestrator).
  • Tests under tools/pz-analyzer/tests/ with synthetic fixtures.
  • Operates exclusively on the already-redacted directory produced by pz_redact_all.sh (.scratch/pz/Logs.redacted/).

Out of scope:

  • Any modification to pz_error_analysis.py, pz_redact_all.sh, or PHP codex source.
  • Filesystem-based mod-scan reattribution (pzmm's symbol-index, vehicle-index, file-path-ownership reattribution requires an actual mod folder we don't have on the server side).
  • iblogs / bosslogs integration. The output schema is designed with that future port in mind, but no PHP code is written here.
  • Generic AI tab patterns from pzmm's core/ai.py. Explicitly excluded.

Architecture

                redacted .txt files
                        |
                        v
          +---------------------------+
          | pz_classify.py            |   argparse · directory walk · aggregate · JSON write
          | (orchestrator)            |
          +-------------+-------------+
                        |
                        v
          +---------------------------+
          | pz_parser.py              |   regexes · parse · classify · sign
          | (pure module, no I/O      |
          |  beyond reading the path  |
          |  it is handed)            |
          +---------------------------+

Two files inside tools/pz-analyzer/:

  • pz_parser.py — stateless. All regex constants, parse_file(path) -> list[Entry], attribution helpers, file:line extractors, cause-chain extractor, signature computation. No argparse, no JSON writing, no directory walking. Unit-testable in isolation.
  • pz_classify.py — entry point. CLI args, walks the redacted directory, calls pz_parser, aggregates records by signature, writes JSON, prints a one-line stats summary.

The split is deliberate: pz_parser.py is the module that eventually wants to be ported to PHP codex (separate spec). Keeping it pure makes that port mechanical and Python-side tests trivial.

Parser pipeline phases

For each *DebugLog-server*.txt, the parser walks lines once and emits records via the following phases.

1. Severity-prefix recognition

Regex: ^\s*(ERROR|SEVERE|WARN)\s*[:\s]. Broader than the existing pz_error_analysis.py regex — adds SEVERE (Java util-logging convention; appears in some PZ Java exception blocks). LOG/INFO is ignored at this layer.

2. Stack collection — bidirectional

Pzmm's contribution: PZ emits stack frames before the ERROR/WARN line as often as after.

  • Pre-stack: walk up to 25 lines back from the severity line. Stop at another severity line or 8 collected. Only keep the block if at least one line looks stack-shaped (at , [string ...], function:, file:, .lua markers).
  • Post-stack: walk forward up to 25 lines, gated by engine-noise detection. Stop at another severity line or 8 collected.
  • Merge deduped, preserving order; cap at 8 frames per record.

3. Mod attribution — three buckets

Bucket Trigger Confidence
direct Line itself matches Lua\(\(MOD:([^)]+)\)\) (or the require("X") failed shape, or an explicit needed by <mod> hint elsewhere in the entry) high
inferred No marker on this line, but body is Lua-shaped (see below) and a Lua((MOD:Y)) was emitted within the previous 40 lines medium
unattributed Neither of the above low; mod_id = "__unattributed__"

"Lua-shaped" means the body matches at least one of (case-insensitive): luamanager.getfunctionobject, no such function, exception thrown, runtimeexception, illegalstateexception, or contains the bare token lua. This filter prevents inferred attribution from latching onto unrelated severity lines that happened to fall within the lookback window.

mod_id derives from the marker's raw name with a _norm_mod_key transform: lowercase, strip spaces / apostrophes / hyphens. mod_name preserves the human-readable form.

We do not attempt pzmm's filesystem-based reattribution.

4. File:line extraction — five fallbacks

Tried in order against the entry body and stack frames:

  1. at <path>.lua:<n>
  2. function: ... file: <path>.lua line #<n> (or : <n>)
  3. [string "<path>.lua"]:<n>
  4. quoted path ending in .lua / .txt / .xml / .json / .ini / .cfg / .bin
  5. unquoted path segment beginning with media/, maps/, lua/, scripts/

Returns (file, line); line=0 if the matched form had no line number.

5. Cause-chain extraction

Caused by: <X> chains plus standalone exception lines ((\w+\.)+\w+(Exception|Error): <msg>) are normalised to <ExceptionClass>: <msg> tokens and joined with ->. Up to 6 chain levels, deduped. Captures both Java exception nesting and Lua-wrapped exception chains.

6. Java exception kind detection

DebugLog-server has both Lua and Java exceptions; pzmm targets console.txt which is Lua-dominant. Extension here:

  • kind = "java_exception" when the entry body or stack contains (\w+\.)+\w+(Exception|Error) AND no Lua((MOD:X)) marker is present anywhere in the entry.
  • These typically resolve to mod_id: __unattributed__ because Java code in PZ is engine, not mod. The exception class name becomes part of the message skeleton so similar Java exceptions dedup tightly.

7. Engine-noise tagging

kind = "engine_noise" when the body contains kahluathread.flusherrormessage or dumping lua stack trace. These severity-ERROR lines are PZ's own diagnostic chatter about its error reporting, not actual errors. They stay in the output (consumer can filter on kind).

8. Signature computation

Two-level deterministic identity, both stored on every record:

pattern_id  = sha256(level + normalized_first_line)[:16]
signature   = sha256(pattern_id + mod_id)[:16]

Normalization for pattern_id:

  • Strip session metadata prefix (General f:N, t:N, st:N,N,N,N> shape)
  • Strip body-prefix severity token (ERROR: / SEVERE: / WARN: / FATAL:, case-insensitive) so a body that opens with the severity word still hashes the same as one that doesn't.
  • Flatten double- and single-quoted strings to "<S>" / '<S>'
  • Flatten ≥2-digit numeric runs to <N>
  • Collapse whitespace
  • Truncate to 200 chars

Both fields ride on every record. Two consumer views, neither requires LLM:

  • Per-mod view (signature is the dedup key): one record per (mod_id, error_shape) pair.
  • Pattern fan-out view (group records by pattern_id): see all mods that hit the same shape.

9. Aggregation

Records dedup on signature. On second-and-subsequent occurrences: occurrence_count++, files set-extends, attribution-confidence promotes (direct beats inferred beats unattributed), stack and cause_chain merge.

Output schema

{
  "meta": {
    "input_dir": "/opt/ik-codex/.scratch/pz/Logs.redacted",
    "files_scanned": 6,
    "log_lines_total": 78654,
    "error_lines_total": 30984,
    "unique_signatures": N,
    "unique_patterns": M,
    "redacted": true,
    "started": "ISO8601",
    "finished": "ISO8601"
  },
  "signatures": [
    {
      "signature": "sha256:...",
      "pattern_id": "sha256:...",
      "level": "ERROR",
      "kind": "lua_runtime|require_failed|java_exception|engine_noise|runtime",
      "mod_id": "spongies_clothing",
      "mod_name": "Spongie's Clothing",
      "attribution": "direct|inferred|unattributed",
      "confidence": "high|medium|low",
      "attribution_reason": "...",
      "file": "media/lua/client/X.lua",
      "line": 42,
      "cause_chain": "ExceptionA: msg -> ExceptionB: msg",
      "stack": ["at A.lua:12", "at B.lua:34"],
      "first_seen": {"file": "...", "line": 1234, "timestamp": "26-04-26 17:14:35.128"},
      "occurrence_count": 47,
      "files": ["..."],
      "excerpt": "..."
    }
  ],
  "summary": {
    "errors": N,
    "warnings": N,
    "by_kind": {"lua_runtime": ..., "java_exception": ..., "require_failed": ..., "engine_noise": ..., "runtime": ...},
    "by_attribution": {"direct": ..., "inferred": ..., "unattributed": ...},
    "by_confidence": {"high": ..., "medium": ..., "low": ...},
    "top_mods": [{"mod_id": "...", "mod_name": "...", "occurrence_count": N}, ...]
  }
}

Default output path: /opt/ik-codex/.scratch/pz/classify.json (gitignored under .scratch/).

CLI

pz_classify.py [--input <dir>] [--out <path>] [--quiet]
  • --input defaults to <repo>/.scratch/pz/Logs.redacted
  • --out defaults to <repo>/.scratch/pz/classify.json
  • --quiet suppresses the trailing summary line

No --limit, --resume, or --checkpoint-every. Runs in seconds; nothing to throttle or resume.

Tests

New directory tools/pz-analyzer/tests/. Stdlib unittest. Three files, ~18 tests total.

  • test_parser.py (~10 tests) — one fixture per scenario in tests/fixtures/ (synthetic, tracked in git): pure-Lua-attributed, pure-Java-exception, inferred-from-context, unattributed-engine-noise, multi-cause-chain, pre-stack-collection, post-stack-collection, severity-variants, file-line-extraction-fallbacks. All synthetic identifiers (placeholder Steam IDs / mod names) per the existing PHP-side test/src/Games/ProjectZomboid/fixtures/ convention.
  • test_attribution.py (~5 tests) — three confidence buckets, the 40-line lookback boundary, "needed by X" extraction, and the rejection of inferred attribution when the message isn't Lua-shaped.
  • test_signatures.py (~3 tests) — pattern_id stability across formatting variations (whitespace, numeric values, quoted strings) and signature uniqueness across mods.

Invocation: python -m unittest discover tools/pz-analyzer/tests/. No external deps.

Verification

End-to-end smoke against the redacted real-data directory:

bash /opt/ik-codex/tools/pz-analyzer/pz_redact_all.sh   # one-time, already done
python /opt/ik-codex/tools/pz-analyzer/pz_classify.py

Expect:

  • 6 files scanned, ~30,984 error lines processed.
  • A meaningful number of unique signatures and patterns (likely in the low hundreds for signatures; fewer patterns).
  • top_mods lists the highest-occurrence mods.
  • PII audit: no real Steam IDs, IPs, or coordinates in the output JSON (input is already redacted; classifier doesn't introduce PII).

Test invocation: python -m unittest discover tools/pz-analyzer/tests/ should be all-green.

Risks and open questions

  • Inferred attribution accuracy. The 40-line lookback is pzmm's heuristic; it's correct for tightly-paced server bursts but can mis-attribute when an unrelated mod logs in the gap. Surface as confidence: medium so consumers can choose to treat them differently. Acceptable for v1; tunable via a constant in pz_parser.py.
  • Pzmm targets console.txt, we target DebugLog-server.txt. Format overlap is high (both share Lua((MOD:X)) markers, Caused-by chains, Java exception shapes), but some patterns may be console.txt-specific. Tests use DebugLog-server-shaped fixtures only.
  • Future PHP port. pz_parser.py is structured for mechanical translation to a LuaErrorAnalyser / ModAttributionAnalyser pair under src/Analyser/ProjectZomboid/ in a separate spec. Output schema chosen to be PHP-codex-compatible (Insight subclasses with typed fields).
  • Licence. The paraxaQQ/pzmm zip we reviewed has no top-level LICENSE; this spec mandates rewriting the patterns originally rather than copying code. Regex shapes and heuristics are general programming patterns and not author-specific, but no code blocks are lifted verbatim.

Out of scope (explicit)

  • Editing pz_error_analysis.py or pz_redact_all.sh.
  • Modifying any file in /opt/ik-codex/src/, /opt/ik-codex/test/, or /opt/iblogs/.
  • AI / LLM integration of any kind in the new tool.
  • LLM inference at runtime in iblogs / bosslogs production. The Qwen analyzer (pz_error_analysis.py) is a developer-only discovery tool used to expand the deterministic ruleset in pz_parser.py (and its future PHP port). Production rendering is deterministic-only, forever.
  • iblogs front-end rendering of the classification output.
  • Filesystem mod-scan reattribution (pzmm's symbol/vehicle indexes).