eq-layer 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {eq_layer-0.2.1 → eq_layer-0.2.3}/.gitignore +1 -0
  2. {eq_layer-0.2.1 → eq_layer-0.2.3}/PKG-INFO +20 -1
  3. {eq_layer-0.2.1 → eq_layer-0.2.3}/README.md +19 -0
  4. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/__init__.py +13 -3
  5. eq_layer-0.2.3/eq_layer/_deps.py +62 -0
  6. eq_layer-0.2.3/eq_layer/_version.py +57 -0
  7. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/auditor.py +2 -4
  8. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/cli.py +20 -7
  9. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/dialogue_reference.py +5 -2
  10. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/intent.py +2 -1
  11. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/interaction.py +1 -4
  12. eq_layer-0.2.3/eq_layer/model_state.py +99 -0
  13. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/preferences.py +2 -4
  14. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/response_experiment.py +87 -15
  15. eq_layer-0.2.3/eq_layer/textnorm.py +42 -0
  16. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/trained_affect.py +23 -7
  17. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/trained_breakdown.py +21 -7
  18. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/trained_disagreement.py +21 -7
  19. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/trained_intent.py +87 -12
  20. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/trained_subtext.py +25 -11
  21. {eq_layer-0.2.1 → eq_layer-0.2.3}/pyproject.toml +4 -1
  22. {eq_layer-0.2.1 → eq_layer-0.2.3}/LICENSE +0 -0
  23. {eq_layer-0.2.1 → eq_layer-0.2.3}/THIRD_PARTY_DATA.md +0 -0
  24. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/ab_eval.py +0 -0
  25. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/actions.py +0 -0
  26. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/affect.py +0 -0
  27. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/coarse_discourse.py +0 -0
  28. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/data/intent_train.jsonl +0 -0
  29. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/dbdc3.py +0 -0
  30. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/dialogbank_iso.py +0 -0
  31. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/intent_belief.py +0 -0
  32. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/policies.py +0 -0
  33. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/steer.py +0 -0
  34. {eq_layer-0.2.1 → eq_layer-0.2.3}/eq_layer/tracker.py +0 -0
@@ -3,6 +3,7 @@ __pycache__/
3
3
  .venv/
4
4
  venv/
5
5
  .env
6
+ .npmrc
6
7
  .DS_Store
7
8
  .idea/
8
9
  .vscode/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: eq-layer
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Inspectable intent, affect, repair, uncertainty, and factored-action control for LLM responses
5
5
  Project-URL: Homepage, https://github.com/Furox-Art/eq-layer
6
6
  Project-URL: Repository, https://github.com/Furox-Art/eq-layer
@@ -554,6 +554,25 @@ detection — were tested and retained as no-go results instead of being wired
554
554
  into policy routing. The tracker exposes where labels are direct, derived,
555
555
  structural, fallback, or externally verified.
556
556
 
557
+ ## Publishing
558
+
559
+ Two artifacts, one semantic version: PyPI `eq-layer` (the Python runtime and
560
+ control layer) and npm `eq-layer` (the portable Agent Skill installer and CLI
561
+ bridge). Each side keeps its version in exactly one canonical location, and the
562
+ release workflows verify the GitHub release tag against those fields before
563
+ uploading.
564
+
565
+ npm publishing picks its credential mode at run time, based on repository
566
+ settings only:
567
+
568
+ - `NPM_TOKEN` configured: token mode, `npm publish --access public
569
+ --no-provenance`;
570
+ - no `NPM_TOKEN`: trusted publishing over GitHub OIDC, `npm publish
571
+ --access public --provenance`.
572
+
573
+ Provenance is an OIDC-only attestation, so it is only requested when no token is
574
+ configured. See `docs/PUBLISHING.md` for the full flow.
575
+
557
576
  ## Contributing
558
577
 
559
578
  Cases are the main entry point. Open one for a failure mode you can describe concretely — especially the ones where two policies seem equally right.
@@ -519,6 +519,25 @@ detection — were tested and retained as no-go results instead of being wired
519
519
  into policy routing. The tracker exposes where labels are direct, derived,
520
520
  structural, fallback, or externally verified.
521
521
 
522
+ ## Publishing
523
+
524
+ Two artifacts, one semantic version: PyPI `eq-layer` (the Python runtime and
525
+ control layer) and npm `eq-layer` (the portable Agent Skill installer and CLI
526
+ bridge). Each side keeps its version in exactly one canonical location, and the
527
+ release workflows verify the GitHub release tag against those fields before
528
+ uploading.
529
+
530
+ npm publishing picks its credential mode at run time, based on repository
531
+ settings only:
532
+
533
+ - `NPM_TOKEN` configured: token mode, `npm publish --access public
534
+ --no-provenance`;
535
+ - no `NPM_TOKEN`: trusted publishing over GitHub OIDC, `npm publish
536
+ --access public --provenance`.
537
+
538
+ Provenance is an OIDC-only attestation, so it is only requested when no token is
539
+ configured. See `docs/PUBLISHING.md` for the full flow.
540
+
522
541
  ## Contributing
523
542
 
524
543
  Cases are the main entry point. Open one for a failure mode you can describe concretely — especially the ones where two policies seem equally right.
@@ -1,14 +1,21 @@
1
- __version__ = "0.2.1"
2
-
1
+ from ._deps import OptionalDependencyError
2
+ from ._version import __version__
3
3
  from .affect import AffectAdapter, HeuristicAffect, load_cases
4
4
  from .auditor import AuditContext, AuditIssue, ResponseAudit, ResponseAuditor, audit_from_generation_metadata
5
5
  from .actions import FactoredAction, RealizationControls, compose_action
6
6
  from .intent import HeuristicIntent, IntentAdapter, IntentState
7
7
  from .intent_belief import IntentBelief, IntentRiskDecision, decide_intent_action, expected_loss
8
8
  from .interaction import InteractionQualityState, RepairState, infer_interaction_quality, infer_repair_state
9
+ from .model_state import ModelStateError
9
10
  from .policies import POLICIES, REGISTRY, SUBTEXTS, AffectState, Policy, Register, Selector
10
11
  from .preferences import SessionPreferences, infer_session_preferences
11
- from .response_experiment import CommandModel, FullEQPipeline, generate_pairs, validate_experiment_cases
12
+ from .response_experiment import (
13
+ CommandModel,
14
+ FullEQPipeline,
15
+ ModelCommandTimeout,
16
+ generate_pairs,
17
+ validate_experiment_cases,
18
+ )
12
19
  from .steer import Steer, aggregate, score_case
13
20
  from .trained_affect import DimensionalAffect, TrainedAffect
14
21
  from .trained_intent import TrainedIntent
@@ -17,6 +24,8 @@ from .tracker import AnnotationStanceResolver, ConversationTracker, StanceDecisi
17
24
 
18
25
  __all__ = [
19
26
  "__version__",
27
+ "OptionalDependencyError",
28
+ "ModelStateError",
20
29
  "AffectAdapter",
21
30
  "AffectState",
22
31
  "audit_from_generation_metadata",
@@ -62,6 +71,7 @@ __all__ = [
62
71
  "AnnotationStanceResolver",
63
72
  "CommandModel",
64
73
  "FullEQPipeline",
74
+ "ModelCommandTimeout",
65
75
  "generate_pairs",
66
76
  "validate_experiment_cases",
67
77
  "score_case",
@@ -0,0 +1,62 @@
1
+ """Typed boundary for the optional ``ml`` extra.
2
+
3
+ The trained adapters import scikit-learn and their artifact loaders import
4
+ joblib; both ship in ``eq-layer[ml]``. Every optional import in the package
5
+ goes through this module so a missing or unusable extra surfaces as one typed,
6
+ actionable error that names the package and the install command, instead of a
7
+ bare ``ImportError`` traceback at request time (audit M-2/M-7).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ ML_EXTRA = "ml"
13
+ ML_PACKAGE = "scikit-learn"
14
+ JOBLIB_PACKAGE = "joblib"
15
+ ML_INSTALL_COMMAND = 'pip install "eq-layer[ml]"'
16
+
17
+
18
+ class OptionalDependencyError(ImportError):
19
+ """An optional dependency of the ``ml`` extra is missing or unusable.
20
+
21
+ Subclasses ``ImportError`` so existing ``except ImportError`` fallbacks
22
+ (the CLI's heuristic intent fallback under ``--intent-mode auto``) keep
23
+ working, while callers that want to map the failure to their own exit code
24
+ or message can catch the typed error instead of matching on message text.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ purpose: str,
30
+ *,
31
+ package: str = ML_PACKAGE,
32
+ cause: BaseException | None = None,
33
+ install_command: str = ML_INSTALL_COMMAND,
34
+ ) -> None:
35
+ detail = f" ({cause})" if cause is not None else ""
36
+ super().__init__(
37
+ f"{purpose} requires the optional ML dependencies: "
38
+ f"{package} is unavailable{detail}. "
39
+ f"Install with: {install_command}"
40
+ )
41
+ self.purpose = purpose
42
+ self.package = package
43
+ self.install_command = install_command
44
+
45
+
46
+ def missing_ml_dependency(purpose: str, cause: BaseException) -> OptionalDependencyError:
47
+ """Build the typed error for a failed scikit-learn import."""
48
+ return OptionalDependencyError(purpose, package=ML_PACKAGE, cause=cause)
49
+
50
+
51
+ def missing_joblib(purpose: str, cause: BaseException) -> OptionalDependencyError:
52
+ """Build the typed error for a failed joblib import."""
53
+ return OptionalDependencyError(purpose, package=JOBLIB_PACKAGE, cause=cause)
54
+
55
+
56
+ def ml_extra_available() -> bool:
57
+ """Report whether the optional ML extra can be imported at all."""
58
+ try:
59
+ import sklearn # noqa: F401
60
+ except ImportError:
61
+ return False
62
+ return True
@@ -0,0 +1,57 @@
1
+ """Single source of truth for the EQ-Layer Python package version.
2
+
3
+ The editable location is the ``version`` field of the ``[project]`` table in
4
+ ``pyproject.toml``. When the distribution is installed, ``importlib.metadata``
5
+ is authoritative; from a source checkout the same field is read from the
6
+ nearest ``pyproject.toml`` so both paths report the identical string instead
7
+ of a hardcoded copy that drifts (audit M4).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from importlib.metadata import PackageNotFoundError, version
14
+ from pathlib import Path
15
+
16
+ DISTRIBUTION = "eq-layer"
17
+
18
+ _PROJECT_HEADER = re.compile(r"(?m)^\[project\]\s*$")
19
+ _NEXT_TABLE = re.compile(r"(?m)^\[")
20
+ _VERSION_KEY = re.compile(r'(?m)^version\s*=\s*"([^"]+)"')
21
+
22
+
23
+ def _project_table(pyproject_text: str) -> str:
24
+ """Return the ``[project]`` table body, or "" when absent.
25
+
26
+ Scoping the search matters: an unrelated ``version =`` key under another
27
+ table must not win over the package version.
28
+ """
29
+ header = _PROJECT_HEADER.search(pyproject_text)
30
+ if header is None:
31
+ return ""
32
+ rest = pyproject_text[header.end() :]
33
+ next_table = _NEXT_TABLE.search(rest)
34
+ return rest[: next_table.start()] if next_table else rest
35
+
36
+
37
+ def _source_checkout_version() -> str | None:
38
+ here = Path(__file__).resolve()
39
+ for parent in here.parents:
40
+ pyproject = parent / "pyproject.toml"
41
+ if not pyproject.is_file():
42
+ continue
43
+ match = _VERSION_KEY.search(_project_table(pyproject.read_text(encoding="utf-8")))
44
+ if match:
45
+ return match.group(1)
46
+ return None
47
+
48
+
49
+ def detect_version() -> str:
50
+ try:
51
+ return version(DISTRIBUTION)
52
+ except PackageNotFoundError:
53
+ # Source checkout without an installed distribution.
54
+ return _source_checkout_version() or "0.0.0+unknown"
55
+
56
+
57
+ __version__ = detect_version()
@@ -4,6 +4,8 @@ import re
4
4
  from dataclasses import asdict, dataclass
5
5
  from difflib import SequenceMatcher
6
6
 
7
+ from .textnorm import normalise_light as _normalise
8
+
7
9
 
8
10
  ABSOLUTE_PRIVACY_PROMISES = (
9
11
  "kimseye söylemem",
@@ -77,10 +79,6 @@ class ResponseAudit:
77
79
  }
78
80
 
79
81
 
80
- def _normalise(text: str) -> str:
81
- return " ".join(text.lower().split())
82
-
83
-
84
82
  def _sentence_starts(text: str) -> tuple[str, ...]:
85
83
  parts = re.split(r"(?<=[.!?])\s+|\n+", _normalise(text))
86
84
  return tuple(part.strip(" -—:;,.!?") for part in parts if part.strip())
@@ -7,6 +7,8 @@ import json
7
7
  import sys
8
8
  from dataclasses import asdict
9
9
 
10
+ from ._deps import ML_INSTALL_COMMAND, OptionalDependencyError, ml_extra_available
11
+
10
12
 
11
13
  def _load_messages(raw: str) -> list[dict]:
12
14
  payload = json.loads(raw)
@@ -38,9 +40,11 @@ def _intent_adapter(mode: str):
38
40
  return HeuristicIntent(), "heuristic"
39
41
  try:
40
42
  return TrainedIntent.from_bundled(), "trained-tfidf-logreg"
41
- except ImportError:
43
+ except OptionalDependencyError:
42
44
  if mode == "learned":
43
45
  raise
46
+ # A missing optional extra is not a routing failure: degrade to the
47
+ # zero-dependency heuristic path and say which backend answered.
44
48
  return HeuristicIntent(), "heuristic-fallback-no-ml-extra"
45
49
 
46
50
 
@@ -125,16 +129,19 @@ def _route(args: argparse.Namespace) -> int:
125
129
 
126
130
 
127
131
  def _doctor() -> int:
132
+ from . import __version__
133
+
134
+ ml_available = ml_extra_available()
128
135
  payload = {
129
136
  "package": "eq-layer",
130
- "version": "0.2.1",
137
+ "version": __version__,
131
138
  "python": sys.version.split()[0],
132
- "ml_extra_available": True,
139
+ "ml_extra_available": ml_available,
133
140
  }
134
- try:
135
- import sklearn # noqa: F401
136
- except ImportError:
137
- payload["ml_extra_available"] = False
141
+ if not ml_available:
142
+ payload["ml_extra_hint"] = (
143
+ f"Optional ML dependencies are unavailable. Install with: {ML_INSTALL_COMMAND}"
144
+ )
138
145
  print(json.dumps(payload, indent=2))
139
146
  return 0
140
147
 
@@ -165,6 +172,12 @@ def main(argv: list[str] | None = None) -> int:
165
172
  args = parser.parse_args(argv)
166
173
  try:
167
174
  return int(args.handler(args))
175
+ except OptionalDependencyError as exc:
176
+ # A missing optional extra is a configuration problem, not a usage
177
+ # error: report it on stderr with the install command and exit 2
178
+ # without dumping the usage block on top of the actionable message.
179
+ print(f"{parser.prog}: error: {exc}", file=sys.stderr)
180
+ return 2
168
181
  except (ValueError, json.JSONDecodeError, ImportError) as exc:
169
182
  parser.error(str(exc))
170
183
  return 2
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import re
4
4
  from dataclasses import dataclass
5
5
 
6
+ from .textnorm import normalise_plain
7
+
6
8
 
7
9
  @dataclass(frozen=True)
8
10
  class GoalCandidate:
@@ -106,8 +108,9 @@ _USER_GOAL_MARKERS = (
106
108
 
107
109
 
108
110
  def _normalise(text: str) -> str:
109
- lowered = text.lower().replace("\u0307", "")
110
- return " ".join(re.sub(r"[^\w\s]", " ", lowered).split())
111
+ # Drop the Turkish combining dot above so dotted and dotless spellings of
112
+ # the same word compare equal before the shared punctuation pass.
113
+ return normalise_plain(text, drop_combining_dot_above=True)
111
114
 
112
115
 
113
116
  def _excerpt(text: str) -> str:
@@ -5,6 +5,7 @@ from dataclasses import dataclass
5
5
  from typing import Protocol
6
6
 
7
7
  from .intent_belief import IntentBelief, IntentRiskDecision
8
+ from .textnorm import normalise_light
8
9
 
9
10
 
10
11
  @dataclass(frozen=True)
@@ -179,7 +180,7 @@ class HeuristicIntent:
179
180
  return f'{prefix}: "{request}".{suffix}'
180
181
 
181
182
  def _normalise(self, text: str) -> str:
182
- return " ".join(text.lower().split())
183
+ return normalise_light(text)
183
184
 
184
185
  def _first_marker(self, text: str, markers: tuple[str, ...]) -> str | None:
185
186
  for marker in markers:
@@ -4,6 +4,7 @@ import re
4
4
  from dataclasses import dataclass
5
5
 
6
6
  from .affect import CORRECTION_MARKERS
7
+ from .textnorm import normalise_plain as _normalise
7
8
 
8
9
 
9
10
  @dataclass(frozen=True)
@@ -42,10 +43,6 @@ class InteractionQualityState:
42
43
  evidence: tuple[str, ...] = ()
43
44
 
44
45
 
45
- def _normalise(text: str) -> str:
46
- return " ".join(re.sub(r"[^\w\s]", " ", text.lower()).split())
47
-
48
-
49
46
  def _has_correction(text: str) -> bool:
50
47
  low = text.lower()
51
48
  return any(marker in low for marker in CORRECTION_MARKERS)
@@ -0,0 +1,99 @@
1
+ """Schema validation for persisted trained-model state (audit H-2).
2
+
3
+ A trained adapter is restored from a joblib artifact. Before this validation
4
+ the loaders indexed the payload directly, so a malformed artifact surfaced as a
5
+ raw ``KeyError`` for a missing key, or a ``TypeError``/``ValueError`` raised
6
+ from inside the dataclass constructor for the wrong field type. Either way the
7
+ caller got an unhandled path with no hint about which field was at fault.
8
+
9
+ Every loader in ``eq_layer/trained_*.py`` now validates the payload schema
10
+ before constructing the adapter, and raises one typed error naming the offending
11
+ field.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ # A field that must be present and non-None but whose value is an opaque fitted
21
+ # estimator, so its concrete type is deliberately not pinned here.
22
+ FITTED = object()
23
+
24
+ _FLOAT_TYPES = (int, float)
25
+
26
+
27
+ class ModelStateError(ValueError):
28
+ """A persisted model artifact failed schema validation at load time."""
29
+
30
+ def __init__(
31
+ self,
32
+ model: str,
33
+ field: str,
34
+ problem: str,
35
+ path: str | Path | None = None,
36
+ ) -> None:
37
+ location = f" in {path}" if path is not None else ""
38
+ super().__init__(
39
+ f"{model} artifact{location} has an invalid {field!r} field: {problem}"
40
+ )
41
+ self.model = model
42
+ self.field = field
43
+ self.problem = problem
44
+
45
+
46
+ def _type_names(types: tuple[type, ...]) -> str:
47
+ return " or ".join(entry.__name__ for entry in types)
48
+
49
+
50
+ def _check(
51
+ model: str,
52
+ field: str,
53
+ value: Any,
54
+ accepted: Any,
55
+ path: str | Path | None,
56
+ ) -> None:
57
+ if accepted is FITTED:
58
+ if value is None:
59
+ raise ModelStateError(model, field, "must not be None", path)
60
+ return
61
+ if not isinstance(value, accepted):
62
+ raise ModelStateError(
63
+ model,
64
+ field,
65
+ f"expected {_type_names(accepted)}, got {type(value).__name__}",
66
+ path,
67
+ )
68
+
69
+
70
+ def validate_state(
71
+ payload: Any,
72
+ *,
73
+ model: str,
74
+ path: str | Path | None = None,
75
+ required: Mapping[str, Any] | None = None,
76
+ optional: Mapping[str, Any] | None = None,
77
+ ) -> None:
78
+ """Validate a persisted model artifact before it is used.
79
+
80
+ ``required``/``optional`` map a field name to an accepted type tuple, or to
81
+ ``FITTED`` for an opaque fitted estimator. A missing required key, an empty
82
+ payload and a wrongly typed field each raise ``ModelStateError`` naming the
83
+ offending field.
84
+ """
85
+ if not isinstance(payload, Mapping):
86
+ raise ModelStateError(
87
+ model,
88
+ "payload",
89
+ f"expected a mapping of fields, got {type(payload).__name__}",
90
+ path,
91
+ )
92
+
93
+ for spec, must_exist in ((required or {}, True), (optional or {}, False)):
94
+ for field, accepted in spec.items():
95
+ if field not in payload:
96
+ if must_exist:
97
+ raise ModelStateError(model, field, "missing required key", path)
98
+ continue
99
+ _check(model, field, payload[field], accepted, path)
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import re
4
4
  from dataclasses import asdict, dataclass, replace
5
5
 
6
+ from .textnorm import normalise_light as _normalise
7
+
6
8
 
7
9
  SESSION_SCOPE_PATTERNS = (
8
10
  r"\bbundan sonra\b",
@@ -44,10 +46,6 @@ class SessionPreferences:
44
46
  return asdict(self)
45
47
 
46
48
 
47
- def _normalise(text: str) -> str:
48
- return " ".join(text.lower().split())
49
-
50
-
51
49
  def _session_scoped(text: str) -> bool:
52
50
  low = _normalise(text)
53
51
  if any(re.search(pattern, low) for pattern in SESSION_SCOPE_PATTERNS):
@@ -6,6 +6,7 @@ import random
6
6
  import re
7
7
  import shlex
8
8
  import subprocess
9
+ import threading
9
10
  from dataclasses import dataclass, field
10
11
  from pathlib import Path
11
12
  from typing import Iterable
@@ -28,6 +29,42 @@ LEAKAGE_FIELDS = {
28
29
  }
29
30
 
30
31
 
32
+ class ModelCommandTimeout(RuntimeError):
33
+ """A model command failed to answer within its configured timeout.
34
+
35
+ Raised for both the one-shot and the persistent command modes so callers
36
+ (including the A/B generation CLI) can catch one typed error instead of
37
+ pattern-matching messages. Subclasses ``RuntimeError`` like the other
38
+ model-command failures.
39
+ """
40
+
41
+
42
+ def _force_kill(process: subprocess.Popen) -> None:
43
+ """Best-effort hard kill of a wedged child so its pipes close.
44
+
45
+ Used by the persistent-command timeout path and as the escalation step of
46
+ ``CommandModel.close``. Terminate first, then kill if the child ignores
47
+ it; both are no-ops when the process has already been reaped.
48
+ """
49
+ try:
50
+ process.terminate()
51
+ except OSError:
52
+ pass
53
+ try:
54
+ process.wait(timeout=5)
55
+ return
56
+ except subprocess.TimeoutExpired:
57
+ pass
58
+ try:
59
+ process.kill()
60
+ except OSError:
61
+ pass
62
+ try:
63
+ process.wait(timeout=5)
64
+ except subprocess.TimeoutExpired:
65
+ pass
66
+
67
+
31
68
  def validate_experiment_cases(cases: list[dict], *, final: bool = False) -> dict:
32
69
  errors: list[str] = []
33
70
  seen: set[str] = set()
@@ -222,14 +259,20 @@ class CommandModel:
222
259
  if self.persistent:
223
260
  raw = self._persistent_generate(payload)
224
261
  else:
225
- completed = subprocess.run(
226
- list(self.command),
227
- input=json.dumps(payload, ensure_ascii=False),
228
- text=True,
229
- capture_output=True,
230
- timeout=self.timeout_seconds,
231
- check=False,
232
- )
262
+ try:
263
+ completed = subprocess.run(
264
+ list(self.command),
265
+ input=json.dumps(payload, ensure_ascii=False),
266
+ text=True,
267
+ capture_output=True,
268
+ timeout=self.timeout_seconds,
269
+ check=False,
270
+ )
271
+ except subprocess.TimeoutExpired as exc:
272
+ raise ModelCommandTimeout(
273
+ f"Model command did not respond within "
274
+ f"{self.timeout_seconds} seconds: {' '.join(self.command)}"
275
+ ) from exc
233
276
  if completed.returncode != 0:
234
277
  raise RuntimeError(
235
278
  f"Model command failed ({completed.returncode}): "
@@ -261,7 +304,7 @@ class CommandModel:
261
304
 
262
305
  process.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n")
263
306
  process.stdin.flush()
264
- raw = process.stdout.readline().strip()
307
+ raw = self._read_line(process, self.timeout_seconds)
265
308
  if not raw:
266
309
  stderr = process.stderr.read().strip() if process.stderr else ""
267
310
  raise RuntimeError(
@@ -270,6 +313,40 @@ class CommandModel:
270
313
  )
271
314
  return raw
272
315
 
316
+ def _read_line(self, process: subprocess.Popen, timeout_seconds: float) -> str:
317
+ """Read one stdout line from the child under a wall-clock deadline.
318
+
319
+ A blocking ``readline()`` on a pipe waits until the child writes a
320
+ newline, and on Windows a blocked pipe read cannot be interrupted, so
321
+ the read runs on a daemon reader thread that is joined with the
322
+ configured timeout. On expiry the child is force-killed: its stdout
323
+ closes, the blocked read unwinds with EOF, and a wedged model command
324
+ can never stall the experiment synchronously.
325
+ """
326
+ stdout = process.stdout
327
+ if stdout is None:
328
+ raise RuntimeError("Persistent model command pipes are unavailable.")
329
+
330
+ lines: list[str] = []
331
+
332
+ def _read() -> None:
333
+ lines.append(stdout.readline())
334
+
335
+ reader = threading.Thread(
336
+ target=_read,
337
+ name="eq-layer-model-reader",
338
+ daemon=True,
339
+ )
340
+ reader.start()
341
+ reader.join(timeout_seconds)
342
+ if reader.is_alive():
343
+ _force_kill(process)
344
+ raise ModelCommandTimeout(
345
+ f"Persistent model command produced no response within "
346
+ f"{timeout_seconds} seconds: {' '.join(self.command)}"
347
+ )
348
+ return lines[0].strip() if lines else ""
349
+
273
350
  @staticmethod
274
351
  def _decode_response(raw: str) -> str:
275
352
  if not raw:
@@ -300,12 +377,7 @@ class CommandModel:
300
377
  try:
301
378
  process.wait(timeout=10)
302
379
  except subprocess.TimeoutExpired:
303
- process.terminate()
304
- try:
305
- process.wait(timeout=5)
306
- except subprocess.TimeoutExpired:
307
- process.kill()
308
- process.wait(timeout=5)
380
+ _force_kill(process)
309
381
 
310
382
 
311
383
  @dataclass
@@ -0,0 +1,42 @@
1
+ """Shared text-normalisation helpers.
2
+
3
+ Five modules used to carry copy-pasted ``_normalise`` helpers, and the copies
4
+ had already drifted into two behaviours:
5
+
6
+ * a light form — lowercase and collapse whitespace, punctuation preserved
7
+ (``auditor``, ``preferences``, ``intent``);
8
+ * a plain form — also replace punctuation runs with spaces (``interaction``),
9
+ with ``dialogue_reference`` additionally dropping the Turkish combining dot
10
+ above (U+0307) so dotted and dotless spellings compare equal.
11
+
12
+ Both behaviours live here, in one place. Callers must keep using the variant
13
+ their matching logic was written against: this consolidation removes the
14
+ duplicated code without silently changing any matcher's semantics.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+ _PUNCTUATION = re.compile(r"[^\w\s]")
22
+ _COMBINING_DOT_ABOVE = "\u0307"
23
+
24
+
25
+ def normalise_light(text: str) -> str:
26
+ """Lowercase and collapse whitespace. Punctuation is preserved."""
27
+ return " ".join(text.lower().split())
28
+
29
+
30
+ def normalise_plain(text: str, *, drop_combining_dot_above: bool = False) -> str:
31
+ """Lowercase, replace punctuation with spaces, and collapse whitespace.
32
+
33
+ ``drop_combining_dot_above`` removes U+0307 before the punctuation pass so
34
+ that ``i`` + combining dot above normalises like a bare ``i``. This is
35
+ needed by Turkish dotted/dotless matching in the dialogue-reference
36
+ matcher; it is off by default so the default behaviour stays identical to
37
+ the historical copies.
38
+ """
39
+ lowered = text.lower()
40
+ if drop_combining_dot_above:
41
+ lowered = lowered.replace(_COMBINING_DOT_ABOVE, "")
42
+ return " ".join(_PUNCTUATION.sub(" ", lowered).split())
@@ -5,7 +5,9 @@ from dataclasses import dataclass, field
5
5
  from pathlib import Path
6
6
  from typing import Iterable
7
7
 
8
+ from ._deps import missing_joblib, missing_ml_dependency
8
9
  from .affect import HeuristicAffect
10
+ from .model_state import FITTED, validate_state
9
11
  from .policies import AffectState
10
12
 
11
13
  EMOBANK_COMMIT = "248ce2a43e165a66d31aeaed83cff9641d6654e0"
@@ -23,10 +25,7 @@ def build_affect_pipeline():
23
25
  from sklearn.linear_model import Ridge
24
26
  from sklearn.pipeline import FeatureUnion, Pipeline
25
27
  except ImportError as exc:
26
- raise ImportError(
27
- "TrainedAffect requires the optional ML dependencies. "
28
- 'Install with: pip install "eq-layer[ml]"'
29
- ) from exc
28
+ raise missing_ml_dependency("TrainedAffect", exc) from exc
30
29
 
31
30
  features = FeatureUnion(
32
31
  [
@@ -205,7 +204,7 @@ class TrainedAffect:
205
204
  try:
206
205
  import joblib
207
206
  except ImportError as exc:
208
- raise ImportError("Saving a trained affect model requires joblib.") from exc
207
+ raise missing_joblib("Saving a trained affect model", exc) from exc
209
208
 
210
209
  joblib.dump(
211
210
  {
@@ -222,8 +221,25 @@ class TrainedAffect:
222
221
  try:
223
222
  import joblib
224
223
  except ImportError as exc:
225
- raise ImportError("Loading a trained affect model requires joblib.") from exc
226
- payload = joblib.load(path)
224
+ raise missing_joblib("Loading a trained affect model", exc) from exc
225
+ try:
226
+ payload = joblib.load(path)
227
+ except ImportError as exc:
228
+ # Unpickling an artifact fitted on another machine needs the same
229
+ # optional extra, so a missing module must surface as a typed error
230
+ # rather than a raw traceback at request time.
231
+ raise missing_ml_dependency("Loading a trained affect model", exc) from exc
232
+ validate_state(
233
+ payload,
234
+ model="TrainedAffect",
235
+ path=path,
236
+ required={"model": FITTED},
237
+ optional={
238
+ "training_examples": (int,),
239
+ "training_source": (str,),
240
+ "training_license": (str,),
241
+ },
242
+ )
227
243
  return cls(
228
244
  model=payload["model"],
229
245
  training_examples=int(payload.get("training_examples", 0)),
@@ -4,6 +4,9 @@ from dataclasses import dataclass
4
4
  from pathlib import Path
5
5
  from typing import Iterable
6
6
 
7
+ from ._deps import missing_joblib, missing_ml_dependency
8
+ from .model_state import FITTED, validate_state
9
+
7
10
 
8
11
  def build_breakdown_pipeline():
9
12
  try:
@@ -11,10 +14,7 @@ def build_breakdown_pipeline():
11
14
  from sklearn.linear_model import SGDClassifier
12
15
  from sklearn.pipeline import FeatureUnion, Pipeline
13
16
  except ImportError as exc:
14
- raise ImportError(
15
- "TrainedBreakdown requires the optional ML dependencies. "
16
- 'Install with: pip install "eq-layer[ml]"'
17
- ) from exc
17
+ raise missing_ml_dependency("TrainedBreakdown", exc) from exc
18
18
 
19
19
  features = FeatureUnion(
20
20
  [
@@ -115,7 +115,7 @@ class TrainedBreakdown:
115
115
  try:
116
116
  import joblib
117
117
  except ImportError as exc:
118
- raise ImportError("Saving breakdown model requires joblib.") from exc
118
+ raise missing_joblib("Saving breakdown model", exc) from exc
119
119
 
120
120
  joblib.dump(
121
121
  {
@@ -132,9 +132,23 @@ class TrainedBreakdown:
132
132
  try:
133
133
  import joblib
134
134
  except ImportError as exc:
135
- raise ImportError("Loading breakdown model requires joblib.") from exc
135
+ raise missing_joblib("Loading breakdown model", exc) from exc
136
136
 
137
- payload = joblib.load(path)
137
+ try:
138
+ payload = joblib.load(path)
139
+ except ImportError as exc:
140
+ raise missing_ml_dependency("Loading breakdown model", exc) from exc
141
+ validate_state(
142
+ payload,
143
+ model="TrainedBreakdown",
144
+ path=path,
145
+ required={"model": FITTED},
146
+ optional={
147
+ "training_examples": (int,),
148
+ "positive_examples": (int,),
149
+ "threshold": (int, float),
150
+ },
151
+ )
138
152
  return cls(
139
153
  model=payload["model"],
140
154
  training_examples=int(payload.get("training_examples", 0)),
@@ -4,6 +4,9 @@ from dataclasses import dataclass
4
4
  from pathlib import Path
5
5
  from typing import Iterable
6
6
 
7
+ from ._deps import missing_joblib, missing_ml_dependency
8
+ from .model_state import FITTED, validate_state
9
+
7
10
  COARSE_DISCOURSE_ARCHIVE_SHA256 = (
8
11
  "33cc25e906e677881c0031c1e134460b1e389fac707f2e52fe59b484dc6d0b61"
9
12
  )
@@ -15,10 +18,7 @@ def build_disagreement_pipeline():
15
18
  from sklearn.linear_model import SGDClassifier
16
19
  from sklearn.pipeline import FeatureUnion, Pipeline
17
20
  except ImportError as exc:
18
- raise ImportError(
19
- "TrainedDisagreement requires the optional ML dependencies. "
20
- 'Install with: pip install "eq-layer[ml]"'
21
- ) from exc
21
+ raise missing_ml_dependency("TrainedDisagreement", exc) from exc
22
22
 
23
23
  features = FeatureUnion(
24
24
  [
@@ -120,7 +120,7 @@ class TrainedDisagreement:
120
120
  try:
121
121
  import joblib
122
122
  except ImportError as exc:
123
- raise ImportError("Saving disagreement model requires joblib.") from exc
123
+ raise missing_joblib("Saving disagreement model", exc) from exc
124
124
 
125
125
  joblib.dump(
126
126
  {
@@ -137,9 +137,23 @@ class TrainedDisagreement:
137
137
  try:
138
138
  import joblib
139
139
  except ImportError as exc:
140
- raise ImportError("Loading disagreement model requires joblib.") from exc
140
+ raise missing_joblib("Loading disagreement model", exc) from exc
141
141
 
142
- payload = joblib.load(path)
142
+ try:
143
+ payload = joblib.load(path)
144
+ except ImportError as exc:
145
+ raise missing_ml_dependency("Loading disagreement model", exc) from exc
146
+ validate_state(
147
+ payload,
148
+ model="TrainedDisagreement",
149
+ path=path,
150
+ required={"model": FITTED},
151
+ optional={
152
+ "training_examples": (int,),
153
+ "positive_examples": (int,),
154
+ "threshold": (int, float),
155
+ },
156
+ )
143
157
  return cls(
144
158
  model=payload["model"],
145
159
  training_examples=int(payload.get("training_examples", 0)),
@@ -2,12 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  from dataclasses import dataclass, field, replace
5
+ from functools import lru_cache
5
6
  from importlib import resources
6
7
  from pathlib import Path
7
8
  from typing import Iterable
8
9
 
10
+ from ._deps import missing_joblib, missing_ml_dependency
9
11
  from .intent import HeuristicIntent, IntentState
10
12
  from .intent_belief import IntentBelief, decide_intent_action, fuse_dialogue_evidence
13
+ from .model_state import FITTED, validate_state
11
14
 
12
15
 
13
16
  def build_pipeline():
@@ -17,10 +20,7 @@ def build_pipeline():
17
20
  from sklearn.linear_model import LogisticRegression
18
21
  from sklearn.pipeline import FeatureUnion, Pipeline
19
22
  except ImportError as exc:
20
- raise ImportError(
21
- "TrainedIntent requires the optional ML dependencies. "
22
- 'Install with: pip install "eq-layer[ml]"'
23
- ) from exc
23
+ raise missing_ml_dependency("TrainedIntent", exc) from exc
24
24
 
25
25
  features = FeatureUnion(
26
26
  [
@@ -64,6 +64,62 @@ def bundled_training_records() -> list[dict]:
64
64
  return [json.loads(line) for line in fh if line.strip()]
65
65
 
66
66
 
67
+ def bundled_records_path() -> Path:
68
+ """Locate the bundled corpus on the filesystem, when it has a path.
69
+
70
+ The cache key needs path+mtime so an edited corpus invalidates the memo.
71
+ Zipped/egg distributions expose no real path, in which case this raises and
72
+ the cache falls back to a stable key (a zipped corpus cannot change under
73
+ the running process anyway).
74
+ """
75
+ here = Path(__file__).resolve()
76
+ for parent in here.parents:
77
+ for candidate in (parent / "eq_layer" / "data", parent / "data"):
78
+ artifact = candidate / "intent_train.jsonl"
79
+ if artifact.is_file():
80
+ return artifact
81
+ raise FileNotFoundError("Bundled intent training corpus has no filesystem path.")
82
+
83
+
84
+ def _bundled_cache_key(
85
+ confidence_threshold: float,
86
+ margin_threshold: float,
87
+ ) -> tuple[str, int, float, float]:
88
+ """Cache key for the bundled fit: artifact path + mtime + thresholds."""
89
+ try:
90
+ artifact = bundled_records_path()
91
+ except FileNotFoundError:
92
+ return (":resources:", 0, float(confidence_threshold), float(margin_threshold))
93
+ return (
94
+ str(artifact),
95
+ artifact.stat().st_mtime_ns,
96
+ float(confidence_threshold),
97
+ float(margin_threshold),
98
+ )
99
+
100
+
101
+ @lru_cache(maxsize=8)
102
+ def _fit_bundled(
103
+ cls: type["TrainedIntent"],
104
+ artifact: str,
105
+ mtime_ns: int,
106
+ confidence_threshold: float,
107
+ margin_threshold: float,
108
+ ) -> "TrainedIntent":
109
+ """Fit once per (artifact, mtime, thresholds); cached for the process."""
110
+ adapter = cls(
111
+ confidence_threshold=confidence_threshold,
112
+ margin_threshold=margin_threshold,
113
+ )
114
+ adapter.fit(bundled_training_records())
115
+ return adapter
116
+
117
+
118
+ def clear_bundled_cache() -> None:
119
+ """Drop the memoised bundled fits (used by tests and long-lived processes)."""
120
+ _fit_bundled.cache_clear()
121
+
122
+
67
123
  @dataclass
68
124
  class TrainedIntent:
69
125
  """Trainable intent adapter.
@@ -86,12 +142,18 @@ class TrainedIntent:
86
142
  confidence_threshold: float = 0.40,
87
143
  margin_threshold: float = 0.08,
88
144
  ) -> "TrainedIntent":
89
- adapter = cls(
90
- confidence_threshold=confidence_threshold,
91
- margin_threshold=margin_threshold,
145
+ """Return the bundled intent adapter, fitting it at most once.
146
+
147
+ The fit is deterministic but expensive (~seconds), so it is memoised
148
+ per process on (artifact path, artifact mtime, both thresholds). A
149
+ repeated call is O(1) and returns the *same* adapter instance, which
150
+ therefore has to be treated as read-only: callers must not call
151
+ ``fit`` on it or they would mutate every other user of the cache.
152
+ """
153
+ return _fit_bundled(
154
+ cls,
155
+ *_bundled_cache_key(confidence_threshold, margin_threshold),
92
156
  )
93
- adapter.fit(bundled_training_records())
94
- return adapter
95
157
 
96
158
  @classmethod
97
159
  def from_jsonl(
@@ -265,7 +327,7 @@ class TrainedIntent:
265
327
  try:
266
328
  import joblib
267
329
  except ImportError as exc:
268
- raise ImportError("Saving a trained model requires joblib.") from exc
330
+ raise missing_joblib("Saving a trained intent model", exc) from exc
269
331
  joblib.dump(
270
332
  {
271
333
  "model": self.model,
@@ -281,8 +343,21 @@ class TrainedIntent:
281
343
  try:
282
344
  import joblib
283
345
  except ImportError as exc:
284
- raise ImportError("Loading a trained model requires joblib.") from exc
285
- payload = joblib.load(path)
346
+ raise missing_joblib("Loading a trained intent model", exc) from exc
347
+ try:
348
+ payload = joblib.load(path)
349
+ except ImportError as exc:
350
+ raise missing_ml_dependency("Loading a trained intent model", exc) from exc
351
+ validate_state(
352
+ payload,
353
+ model="TrainedIntent",
354
+ path=path,
355
+ required={"model": FITTED, "confidence_threshold": (int, float)},
356
+ optional={
357
+ "margin_threshold": (int, float),
358
+ "training_examples": (int,),
359
+ },
360
+ )
286
361
  return cls(
287
362
  confidence_threshold=float(payload["confidence_threshold"]),
288
363
  margin_threshold=float(payload.get("margin_threshold", 0.08)),
@@ -4,7 +4,9 @@ from dataclasses import dataclass, field
4
4
  from pathlib import Path
5
5
  from typing import Iterable
6
6
 
7
+ from ._deps import missing_joblib, missing_ml_dependency
7
8
  from .affect import CORRECTION_MARKERS, DISCLOSURE_MARKERS, HeuristicAffect
9
+ from .model_state import FITTED, validate_state
8
10
  from .policies import AffectState
9
11
 
10
12
  XDAILYDIALOG_COMMIT = "6e7ecf54c9f169215b4b8c18995c7aac74117127"
@@ -104,10 +106,7 @@ def build_feature_extractor():
104
106
  from sklearn.feature_extraction.text import TfidfVectorizer
105
107
  from sklearn.pipeline import FeatureUnion
106
108
  except ImportError as exc:
107
- raise ImportError(
108
- "TrainedSubtext requires the optional ML dependencies. "
109
- 'Install with: pip install "eq-layer[ml]"'
110
- ) from exc
109
+ raise missing_ml_dependency("TrainedSubtext", exc) from exc
111
110
 
112
111
  return FeatureUnion(
113
112
  [
@@ -141,10 +140,7 @@ def build_classifier():
141
140
  try:
142
141
  from sklearn.linear_model import SGDClassifier
143
142
  except ImportError as exc:
144
- raise ImportError(
145
- "TrainedSubtext requires the optional ML dependencies. "
146
- 'Install with: pip install "eq-layer[ml]"'
147
- ) from exc
143
+ raise missing_ml_dependency("TrainedSubtext", exc) from exc
148
144
 
149
145
  return SGDClassifier(
150
146
  loss="log_loss",
@@ -375,7 +371,7 @@ class TrainedSubtext:
375
371
  try:
376
372
  import joblib
377
373
  except ImportError as exc:
378
- raise ImportError("Saving a trained subtext model requires joblib.") from exc
374
+ raise missing_joblib("Saving a trained subtext model", exc) from exc
379
375
 
380
376
  joblib.dump(
381
377
  {
@@ -394,9 +390,27 @@ class TrainedSubtext:
394
390
  try:
395
391
  import joblib
396
392
  except ImportError as exc:
397
- raise ImportError("Loading a trained subtext model requires joblib.") from exc
393
+ raise missing_joblib("Loading a trained subtext model", exc) from exc
398
394
 
399
- payload = joblib.load(path)
395
+ try:
396
+ payload = joblib.load(path)
397
+ except ImportError as exc:
398
+ raise missing_ml_dependency("Loading a trained subtext model", exc) from exc
399
+ validate_state(
400
+ payload,
401
+ model="TrainedSubtext",
402
+ path=path,
403
+ required={
404
+ "features": FITTED,
405
+ "act_model": FITTED,
406
+ "emotion_model": FITTED,
407
+ },
408
+ optional={
409
+ "training_examples": (int,),
410
+ "act_confidence_threshold": (int, float),
411
+ "emotion_confidence_threshold": (int, float),
412
+ },
413
+ )
400
414
  return cls(
401
415
  features=payload["features"],
402
416
  act_model=payload["act_model"],
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "eq-layer"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Inspectable intent, affect, repair, uncertainty, and factored-action control for LLM responses"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -69,6 +69,9 @@ include = [
69
69
  "/THIRD_PARTY_DATA.md",
70
70
  ]
71
71
 
72
+ [tool.pytest.ini_options]
73
+ testpaths = ["tests"]
74
+
72
75
  [tool.ruff]
73
76
  line-length = 100
74
77
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes