behave-text 0.1.0__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.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: behave-text
3
+ Version: 0.1.0
4
+ Summary: BEHAVE-TEXT — text/messaging-domain behavioral observation registry, layered on behave-core
5
+ Author: ANTI
6
+ License: GPL-3.0-or-later
7
+ Project-URL: Source, https://git.resacachile.cl/anti/BEHAVE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: pydantic>=2.6
10
+ Requires-Dist: behave-core>=0.1.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == "dev"
13
+ Requires-Dist: pytest-cov; extra == "dev"
14
+ Requires-Dist: ruff; extra == "dev"
@@ -0,0 +1,196 @@
1
+ <!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
2
+ # behave-text
3
+
4
+ [← repo](../README.md)
5
+
6
+ Text/messaging-domain behavioral observation registry. Defines what can be observed
7
+ about an actor through their written messaging activity — stylometric fingerprints,
8
+ lexical patterns, interaction rhythms, and governance-role signals.
9
+
10
+ BEHAVE-TEXT operates on **derived features, not raw text**. Sensors hash, aggregate,
11
+ and classify before emitting — the raw message content never enters a BEHAVE
12
+ observation. This is a tighter constraint than BEHAVE-SHELL because the source
13
+ signal *is* text content; the PII risk is higher.
14
+
15
+ The topic prefix is `actor.observation.text` (not `attacker.`) because chat groups
16
+ include non-attacker roles — admins, buyers, sellers, bots, lurkers. The framing
17
+ is deliberately neutral: BEHAVE-TEXT observes actors, not adversaries.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install -e ../core/ -e .
23
+ # development:
24
+ pip install -e ../core/ -e ".[dev]"
25
+ ```
26
+
27
+ ## Quickstart
28
+
29
+ ```python
30
+ from behave_text.spec import Observation, Window, TOPIC_PREFIX, event_topic_for
31
+
32
+ obs = Observation(
33
+ primitive="stylometric.capitalization_habit",
34
+ value="lowercase",
35
+ confidence=0.91,
36
+ window=Window(start_ts=1714000000.0, end_ts=1714086400.0),
37
+ source="behave/text-sensor/stylometry.py",
38
+ )
39
+ topic = event_topic_for("stylometric.capitalization_habit")
40
+ # → "actor.observation.text.stylometric.capitalization_habit"
41
+ ```
42
+
43
+ ## Public API (`behave_text.spec`)
44
+
45
+ | Symbol | Description |
46
+ |---|---|
47
+ | `Observation` | Registry-aware subclass of `behave_core.spec.Observation`. Validates `primitive` and `value` against `PRIMITIVE_REGISTRY`. |
48
+ | `Window` | Re-exported from `behave_core`. |
49
+ | `ObservationValue` | Re-exported union type. |
50
+ | `PRIMITIVE_REGISTRY` | `dict[str, ValueTypeSpec]` — the full primitive catalog (35 entries). |
51
+ | `ValueKind` | Enum: `CATEGORICAL`, `NUMERIC`, `HASH`, `ARRAY`, `FREE_STRING`, `BOOL`. |
52
+ | `ValueTypeSpec` | Pydantic model: kind, allowed values, bounds, notes. |
53
+ | `is_known(primitive)` | `bool` — whether a primitive path is registered. |
54
+ | `get(primitive)` | Returns the `ValueTypeSpec`; raises `KeyError` if unknown. |
55
+ | `TOPIC_PREFIX` | `"actor.observation.text"` |
56
+ | `event_topic_for(primitive)` | Returns the full event bus topic string. |
57
+
58
+ Note: `to_event_payload` / `from_event_payload` (full round-trip helpers) are
59
+ present in `behave-shell` but not yet implemented here — `status: planned`.
60
+
61
+ ## Primitives
62
+
63
+ 35 primitives across 6 categories.
64
+
65
+ ---
66
+
67
+ ### `stylometric.*` — Writing style fingerprints (12 primitives)
68
+
69
+ Stylometric primitives capture the unconscious writing habits that distinguish
70
+ one author from another. The field goes back to the Mosteller-Wallace Federalist
71
+ Papers study (1963): function-word frequencies alone can attribute authorship
72
+ with high accuracy in long-form English text. BEHAVE-TEXT adapts these methods
73
+ to short-form Spanish chat, which introduces domain-specific challenges (short
74
+ messages, informal register, code-switching, emoji). Calibration results from
75
+ the Rutify corpus are noted inline where they affect interpretation.
76
+
77
+ | Primitive | Kind | Description |
78
+ |---|---|---|
79
+ | `stylometric.punctuation_style` | hash | Canonical punctuation-pattern fingerprint hash. Captures the author's consistent punctuation tics (double spaces, comma habits, no-period endings) as a searchable signature. |
80
+ | `stylometric.capitalization_habit` | categorical | Dominant capitalization rule. `lowercase` = no capitals. `proper` = standard sentence/title case. `random_caps` = no consistent rule. `mixed_i` = consistent lowercase 'i' mid-sentence — common in Spanish chat where the standalone-'I' habit doesn't apply but the behavior transfers. |
81
+ | `stylometric.emoji_usage` | categorical | Rate of emoji use. `none`, `occasional`, `frequent`, `exclusive` (messages rarely without emoji). Captures tone and register. |
82
+ | `stylometric.emoji_placement` | categorical | Emoji position relative to sentence-ending punctuation. `pre_punctuation` = 'Hola 😊.' `post_punctuation` = 'Hola. 😊' Individual authors are strikingly consistent in this micro-habit. |
83
+ | `stylometric.message_length_class` | categorical | Median message length bucket: `short` 1-5 words, `medium` 6-20, `long` 21-50, `paragraph` >50. See also `message_length_variance_class` for distribution shape. |
84
+ | `stylometric.message_length_variance_class` | categorical | Distribution shape of per-message word counts. `tight` CV<0.5 (always 1-3 words). `varied` 0.5≤CV<1.5 (normal mix). `bimodal` CV≥1.5 (mostly short with occasional rants). Two authors can share the same median length but have wildly different variance. |
85
+ | `stylometric.linebreak_style` | categorical | Whether the author sends one complete thought per message or bursts multiple short sequential messages. `multi_line` = habitual 3-5 short messages per turn. `wall_of_text` = dense blocks, rarely uses line breaks. Captures a stylistic rhythm that is hard to consciously alter. |
86
+ | `stylometric.typo_signature` | hash | SHA-256 of the canonical persistent-typo set — the specific recurring errors the author makes consistently (e.g. always writes `tener` as `tenet`, or `porque` as `xq`). Persistent typos are strong authorship signals because they reflect keyboard-motor habits. |
87
+ | `stylometric.function_word_distribution_top50` | hash | 64-bit SimHash over the 50 most common Spanish function-word frequency vector. Based on the Mosteller-Wallace method. **Calibration note (2026-05-02, Rutify corpus):** within-author and cross-author Hamming distance distributions overlap (within median 8 bits, cross median 10 bits) in short-message chat — this primitive alone cannot discriminate authors. Engines should weight it low and composite with character n-grams and distinctive vocabulary. Kept in v0 for calibration grids. |
88
+ | `stylometric.function_word_distribution_top200` | hash | 64-bit SimHash over the 200 most common Spanish function words. The wider list reaches into the long tail (rare-but-individual words like `tampoco`, `aunque`, `mientras`) that carry more discriminating signal in short-message corpora. Not yet emitted by v0 prototype — populated in v0.2. |
89
+ | `stylometric.character_ngram_simhash` | hash | 64-bit SimHash over character n-gram frequencies (default n=3), lowercased. Orthogonal to function-word distributions: captures punctuation tics, accent-stripping habits, typo patterns, and idiom fragments that survive paraphrase. Accents are preserved because accent-stripping is itself a stylistic tic. Source label declares n size (e.g. `#char3gram`). |
90
+ | `stylometric.distinctive_vocabulary_signature` | hash | 64-bit SimHash over a TF-IDF-weighted top-K rare-word vector. Captures the author's distinctive lexicon — words they use that other authors in the same corpus do not. Complementary to function-word distributions: where `function_word_*` captures common-word style, this captures individual lexical choice. Requires the full corpus for IDF computation. Source label declares top-K and corpus tag (e.g. `#tfidf-top50`). |
91
+
92
+ ---
93
+
94
+ ### `lexical.*` — Vocabulary and linguistic patterns (8 primitives)
95
+
96
+ Lexical primitives characterize *what* and *how* an actor writes at the word and
97
+ sentence level. Where stylometric primitives fingerprint unconscious micro-habits,
98
+ lexical primitives capture deliberate linguistic choices — vocabulary richness,
99
+ how questions are formed, register.
100
+
101
+ | Primitive | Kind | Description |
102
+ |---|---|---|
103
+ | `lexical.vocabulary_richness` | numeric [0,1] | Moving-Average Type-Token Ratio (MATTR) over a sliding window (default 50 tokens). Volume-independent: each window contributes its own unique/total ratio, the value is the mean. Avoids the standard TTR bias where larger corpora mechanically score lower. Source label declares window size. |
104
+ | `lexical.slang_density` | numeric [0,1] | Rate of slang terms per message, against a locale-tuned slang corpus. |
105
+ | `lexical.code_switching_rate` | numeric [0,1] | Language switches per N tokens (Solorio & Liu metric). A speaker who switches between Spanish and English, or Spanish and lunfardo/caló, will have a higher rate than a monolingual writer. |
106
+ | `lexical.code_switching_matrix_language` | free_string | BCP-47 tag of the dominant (matrix) language in code-switching texts (e.g. `es-CL`, `es-AR`). The matrix language is the grammatical scaffold; embedded languages appear as inserts. |
107
+ | `lexical.code_switching_embedded_languages` | array[free_string] | BCP-47 list of non-matrix languages observed in the actor's messages. |
108
+ | `lexical.sentence_complexity_class` | categorical | Dominant clause structure. `simple` = single-clause. `compound` = two independent clauses joined by coordinating conjunctions (pero, y, o). `complex` = dependent clauses and subordination (aunque, porque, cuando). Reflects education level and cognitive investment. |
109
+ | `lexical.question_formation_style` | categorical | How questions are formed. `punctuation_only` = question mark without interrogative words ('¿Cuánto?') — very common in Spanish chat. `lexical` = explicit interrogatives (¿qué, cómo, cuándo). `formal` = inverted subject-verb or formal register. |
110
+ | `lexical.imperative_style` | categorical | How commands and requests are framed. `informal_directive` = tú/vos imperative (dame, hazlo). `formal_directive` = usted imperative (hágame el favor). `polite` = conditional/modal softening (¿podría...?). Stable per-author trait in hierarchical contexts. |
111
+
112
+ ---
113
+
114
+ ### `temporal_evolution.*` — Behavioral change over time (1 primitive)
115
+
116
+ | Primitive | Kind | Description |
117
+ |---|---|---|
118
+ | `temporal_evolution.lifecycle_phase` | categorical | Auto-classified lifecycle stage from windowed within-corpus analysis. `arrival_burst` = first 24hr, first-window volume dominates (empirically validated against OxPayload's first 12 hours in Rutify). `stable_member` = low drift across the full tenure. `fluctuating_member` = tenure ≥24hr with median drift between stable and inflection thresholds — established noisy regulars (e.g. lamarabitch). `inflection_member` = long-tenure actor with a real behavioral shift in at least one window-pair. `declining_member` = monotonically decreasing per-window message counts. `unknown` = insufficient data. Window size adapts to tenure: <24hr → 2h, <7d → 12h, <30d → 1d, otherwise 7d. |
119
+
120
+ ---
121
+
122
+ ### `network.*` — Governance and role signals (2 primitives)
123
+
124
+ Network primitives capture the actor's *structural role* in the group — inferred
125
+ from interaction patterns rather than content — and a bot detector. These are
126
+ heuristic composites built from other primitives; treat them as candidate signals,
127
+ not verdicts.
128
+
129
+ | Primitive | Kind | Description |
130
+ |---|---|---|
131
+ | `network.is_likely_bot` | categorical | Heuristic bot detector. `likely_bot` when `conversation_initiation_rate` ≥ 0.95 AND `attention_pattern` = `broadcast` AND `vocabulary_richness` < 0.65. Validated (2026-05-03) against SangMata_beta_bot (caught) vs 11 high-volume humans (no false positives). Low-volume bots (e.g. QuotLyBot, 9 messages) sit below the fingerprint threshold. Source label declares heuristic version (e.g. `#bot-heuristic-v1`). |
132
+ | `network.governance_role_signal` | categorical | Heuristic role shape from interaction primitives + lifecycle. `admin_pattern` = init_rate ≥ 0.80, attention reciprocal, non-bot, non-arrival_burst. `responder_pattern` = init_rate ≤ 0.45, attention reciprocal. `bot_pattern` = matches `is_likely_bot`. `regular` = everything else above volume threshold. Empirically caught 4/4 high-volume Rutify admins, sebaImlI as responder, SangMata as bot. NOT a ground-truth admin label. |
133
+
134
+ ---
135
+
136
+ ### `interaction.*` — Messaging behavior (6 primitives)
137
+
138
+ Interaction primitives characterize *how* the actor participates in conversations —
139
+ timing, initiation rate, and attention patterns.
140
+
141
+ | Primitive | Kind | Description |
142
+ |---|---|---|
143
+ | `interaction.response_latency_class` | categorical | How quickly the actor responds to messages directed at them. `immediate` <30s (suggests active monitoring or automation). `fast` 30s-5min. `normal` 5-60min. `slow` 1-24hr. `sporadic` = no consistent pattern. |
144
+ | `interaction.conversation_initiation_rate` | numeric [0,1] | Thread-starting messages / total messages. High rate = the actor drives conversations. |
145
+ | `interaction.message_burst_rate` | categorical | Whether the actor sends multiple messages per turn. `habitual` = almost always bursts (3+ messages before any reply). `single` = almost always one message per turn. Tied to `stylometric.linebreak_style multi_line`. |
146
+ | `interaction.active_hours_class` | free_string | UTC active-hours window summary (e.g. `05:00-14:00 UTC`). Free string — the window shape varies by actor and doesn't fit a closed enum. |
147
+ | `interaction.session_duration_class` | categorical | Typical session length: `short` <15min, `medium` 15-90min, `long` 90min-4hr, `marathon` >4hr. Shares the enum with `behave_shell`'s `temporal.session_duration`. |
148
+ | `interaction.attention_pattern` | categorical | Reply-graph centrality shape. `broadcast` = sends to many, replies to few (one-to-many). `focused` = concentrates on a small set of interlocutors. `reciprocal` = balanced give-and-take. |
149
+
150
+ ---
151
+
152
+ ### `content.*` — Content-derived signals, EXPERIMENTAL (6 primitives)
153
+
154
+ Content primitives are derived from message text through classifiers rather than
155
+ structural/timing analysis. They carry the highest risk of false positives, are
156
+ brittle to vocabulary drift, and are locale-specific. An attribution engine may
157
+ choose to weight these at zero until field-validated against labeled data.
158
+
159
+ | Primitive | Kind | Description |
160
+ |---|---|---|
161
+ | `content.role_signal` | categorical | Locale-tuned role-vocabulary classifier. Values: `admin`, `seller`, `buyer`, `lurker`, `newbie`. May be moved to a separate IOC/keyword-detection layer after Rutify testing. `EXPERIMENTAL` |
162
+ | `content.transactional_language` | numeric [0,1] | Rate of transactional terms per message. Locale-specific; brittle to vocabulary drift. `EXPERIMENTAL` |
163
+ | `content.opsec_awareness` | numeric [0,1] | Rate of security-conscious phrases. **HIGH FALSE-POSITIVE RISK** on casual conversation about deleting files/messages. `EXPERIMENTAL` |
164
+ | `content.targeting_language` | array[free_string] | IOC-shaped target patterns (bank names, government portals, RUT ranges). Consider moving to a dedicated IOC layer. `EXPERIMENTAL` |
165
+ | `content.boasting_pattern` | categorical | Success-claim frequency: `none`, `occasional`, `frequent`. Corpus-dependent regex. `EXPERIMENTAL` |
166
+ | `content.conflict_style` | categorical | Dispute-tone classification: `aggressive`, `defusing`, `appellate`. Needs labelled training data. `EXPERIMENTAL` |
167
+
168
+ ---
169
+
170
+ ## Schema
171
+
172
+ Machine-readable JSON Schema:
173
+ [`json/observation.schema.json`](json/observation.schema.json)
174
+
175
+ Regenerate after model changes:
176
+ ```bash
177
+ python scripts/generate_schema.py
178
+ ```
179
+
180
+ ## Tests
181
+
182
+ ```bash
183
+ pytest tests/
184
+ ```
185
+
186
+ ## Attribution recipes
187
+
188
+ [`attribution-recipes.md`](attribution-recipes.md) — placeholder document sketching
189
+ how an external attribution engine would consume `actor.observation.text.*` topics
190
+ to build actor profiles (`credential_broker`, `low_skill_buyer`, `group_admin`, etc.).
191
+ **Not populated yet** — awaiting Rutify corpus calibration. Not part of the BEHAVE spec.
192
+
193
+ ## License
194
+
195
+ Code and schemas: [GPL-3.0-or-later](../LICENSE)
196
+ Spec prose (this file, attribution-recipes.md): [CC-BY-SA-4.0](../LICENSE.docs)
File without changes
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """BEHAVE-TEXT spec — text/messaging-domain registry, layered on behave-core.
3
+
4
+ Public API:
5
+
6
+ from spec import Observation, Window, OBSERVATION_SCHEMA_VERSION
7
+ from spec import PRIMITIVE_REGISTRY, ValueKind, ValueTypeSpec
8
+ from spec import TOPIC_PREFIX, event_topic_for
9
+
10
+ The ``Observation`` exported here is a registry-aware subclass of the base
11
+ class from ``behave-core``; it validates that ``primitive`` is in the
12
+ text registry and that ``value`` matches the registry's per-primitive spec.
13
+
14
+ See ``spec.envelope`` (and the core envelope module) for PII discipline.
15
+ """
16
+
17
+ from .envelope import OBSERVATION_SCHEMA_VERSION, Observation, ObservationValue, Window
18
+ from .primitives import PRIMITIVE_REGISTRY, ValueKind, ValueTypeSpec, get, is_known
19
+
20
+ # Topic namespace deliberately uses *actor* (not *attacker*) because chat-group
21
+ # members may include observers, brokers, victims, and bystanders alongside
22
+ # threat actors. Attribution of role is the engine's job, not BEHAVE-TEXT's.
23
+ TOPIC_PREFIX: str = "actor.observation.text"
24
+
25
+
26
+ def event_topic_for(primitive: str) -> str:
27
+ """Return the canonical bus topic for a BEHAVE-TEXT primitive."""
28
+ return f"{TOPIC_PREFIX}.{primitive}"
29
+
30
+
31
+ __all__ = [
32
+ "OBSERVATION_SCHEMA_VERSION",
33
+ "Observation",
34
+ "ObservationValue",
35
+ "Window",
36
+ "PRIMITIVE_REGISTRY",
37
+ "ValueKind",
38
+ "ValueTypeSpec",
39
+ "is_known",
40
+ "get",
41
+ "TOPIC_PREFIX",
42
+ "event_topic_for",
43
+ ]
@@ -0,0 +1,53 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """BEHAVE-TEXT Observation envelope (registry-aware subclass).
3
+
4
+ Mirrors BEHAVE-SHELL's pattern: structural envelope from `behave-core`,
5
+ registry-aware validation added here against BEHAVE-TEXT's `PRIMITIVE_REGISTRY`.
6
+
7
+ PII discipline (TIGHTER for text than for shell):
8
+ text-domain sensors operate on raw message bodies. They MUST hash, aggregate,
9
+ or categorize before constructing an Observation — never put message text
10
+ into the `value` or `evidence_ref` field. `evidence_ref` should point at an
11
+ external message-store record (e.g. a Telegram message ID), not at the text.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pydantic import model_validator
17
+
18
+ from behave_core.spec.envelope import (
19
+ OBSERVATION_SCHEMA_VERSION,
20
+ ObservationValue,
21
+ Window,
22
+ )
23
+ from behave_core.spec.envelope import Observation as _BaseObservation
24
+
25
+ from .primitives import PRIMITIVE_REGISTRY
26
+
27
+
28
+ class Observation(_BaseObservation):
29
+ """Text-domain Observation: base envelope + BEHAVE-TEXT registry check."""
30
+
31
+ @model_validator(mode="after")
32
+ def _validate_against_text_registry(self) -> "Observation":
33
+ spec = PRIMITIVE_REGISTRY.get(self.primitive)
34
+ if spec is None:
35
+ raise ValueError(
36
+ f"unknown primitive {self.primitive!r}; "
37
+ f"add it to spec/primitives.py:PRIMITIVE_REGISTRY first"
38
+ )
39
+ try:
40
+ spec.validate_value(self.value)
41
+ except ValueError as exc:
42
+ raise ValueError(
43
+ f"value invalid for primitive {self.primitive!r}: {exc}"
44
+ ) from None
45
+ return self
46
+
47
+
48
+ __all__ = [
49
+ "OBSERVATION_SCHEMA_VERSION",
50
+ "Observation",
51
+ "ObservationValue",
52
+ "Window",
53
+ ]
@@ -0,0 +1,353 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """BEHAVE-TEXT primitive registry.
3
+
4
+ Source-of-truth for what `Observation.primitive` may be in the text/messaging
5
+ domain and what `Observation.value` must look like. Mirrors every row in the
6
+ primitive tables of `scratchpad.md`.
7
+
8
+ PII discipline notice (carried over from behave-core's envelope module):
9
+ TEXT-domain observations carry CATEGORICAL LABELS, AGGREGATE RATES, and
10
+ HASHES of distributions. Sensors operating on Telegram/messaging text MUST
11
+ NOT emit raw message content into BEHAVE-TEXT observations — only derived
12
+ features. The `evidence_ref` field points to the underlying message store
13
+ held elsewhere; never into the message body itself.
14
+
15
+ This is a tighter constraint than BEHAVE-SHELL's because the source signal
16
+ IS text content. Sensors must hash/aggregate before emitting.
17
+
18
+ Adding a new primitive is a deliberate registry edit. Drift between this file
19
+ and `scratchpad.md` is a bug; v0 keeps the registry hand-written so PR review
20
+ catches drift, v0.x may auto-extract from the markdown if drift becomes a
21
+ maintenance issue.
22
+
23
+ Status flags appear in the `notes` field. `EXPERIMENTAL` marks primitives in
24
+ the `content.*` layer whose detector implementations are likely brittle; an
25
+ attribution engine may choose to weight those at zero until field-validated.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from enum import Enum
31
+ from typing import Any, Optional
32
+
33
+ from pydantic import BaseModel, Field
34
+
35
+
36
+ class ValueKind(str, Enum):
37
+ """Discriminator for the shape an `Observation.value` must take."""
38
+
39
+ CATEGORICAL = "categorical"
40
+ NUMERIC = "numeric"
41
+ HASH = "hash"
42
+ ARRAY = "array"
43
+ FREE_STRING = "free_string"
44
+ BOOL = "bool"
45
+
46
+
47
+ class ValueTypeSpec(BaseModel):
48
+ """Per-primitive value-type spec (mirrors BEHAVE-SHELL's shape)."""
49
+
50
+ kind: ValueKind
51
+ allowed: Optional[list[str]] = Field(default=None)
52
+ min_val: Optional[float] = Field(default=None)
53
+ max_val: Optional[float] = Field(default=None)
54
+ array_of: Optional[ValueKind] = Field(default=None)
55
+ notes: Optional[str] = Field(default=None)
56
+
57
+ def validate_value(self, value: Any) -> None:
58
+ if self.kind is ValueKind.CATEGORICAL:
59
+ if not isinstance(value, str):
60
+ raise ValueError(f"expected categorical string, got {type(value).__name__}")
61
+ if self.allowed is not None and value not in self.allowed:
62
+ raise ValueError(f"value {value!r} not in allowed set {self.allowed!r}")
63
+ elif self.kind is ValueKind.NUMERIC:
64
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
65
+ raise ValueError(f"expected numeric, got {type(value).__name__}")
66
+ if self.min_val is not None and value < self.min_val:
67
+ raise ValueError(f"value {value} below min_val {self.min_val}")
68
+ if self.max_val is not None and value > self.max_val:
69
+ raise ValueError(f"value {value} above max_val {self.max_val}")
70
+ elif self.kind is ValueKind.HASH:
71
+ if not isinstance(value, str) or not value:
72
+ raise ValueError("expected non-empty hash string")
73
+ elif self.kind is ValueKind.FREE_STRING:
74
+ if not isinstance(value, str):
75
+ raise ValueError(f"expected string, got {type(value).__name__}")
76
+ elif self.kind is ValueKind.BOOL:
77
+ if not isinstance(value, bool):
78
+ raise ValueError(f"expected bool, got {type(value).__name__}")
79
+ elif self.kind is ValueKind.ARRAY:
80
+ if not isinstance(value, list):
81
+ raise ValueError(f"expected array, got {type(value).__name__}")
82
+ if self.array_of is None:
83
+ return
84
+ element_spec = ValueTypeSpec(kind=self.array_of)
85
+ for i, element in enumerate(value):
86
+ try:
87
+ element_spec.validate_value(element)
88
+ except ValueError as exc:
89
+ raise ValueError(f"array element [{i}]: {exc}") from None
90
+
91
+
92
+ # ─── Convenience constructors ───────────────────────────────────────────────
93
+
94
+ def _cat(*allowed: str, notes: Optional[str] = None) -> ValueTypeSpec:
95
+ return ValueTypeSpec(kind=ValueKind.CATEGORICAL, allowed=list(allowed), notes=notes)
96
+
97
+ def _num(min_val: Optional[float] = None, max_val: Optional[float] = None, notes: Optional[str] = None) -> ValueTypeSpec:
98
+ return ValueTypeSpec(kind=ValueKind.NUMERIC, min_val=min_val, max_val=max_val, notes=notes)
99
+
100
+ def _hash(notes: Optional[str] = None) -> ValueTypeSpec:
101
+ return ValueTypeSpec(kind=ValueKind.HASH, notes=notes)
102
+
103
+ def _str(notes: Optional[str] = None) -> ValueTypeSpec:
104
+ return ValueTypeSpec(kind=ValueKind.FREE_STRING, notes=notes)
105
+
106
+ def _array(of: ValueKind, notes: Optional[str] = None) -> ValueTypeSpec:
107
+ return ValueTypeSpec(kind=ValueKind.ARRAY, array_of=of, notes=notes)
108
+
109
+
110
+ # ─── The registry ───────────────────────────────────────────────────────────
111
+ #
112
+ # 28 primitives across 4 layers. Mirrors scratchpad.md row-for-row.
113
+
114
+ PRIMITIVE_REGISTRY: dict[str, ValueTypeSpec] = {
115
+ # ── stylometric.* (motor analog — 8) ──────────────────────────────────
116
+ "stylometric.punctuation_style": _hash(notes="canonical punctuation-pattern fingerprint"),
117
+ "stylometric.capitalization_habit": _cat(
118
+ "lowercase", "proper", "random_caps", "mixed_i",
119
+ notes="Dominant capitalization rule the author applies. lowercase=no capitals except "
120
+ "after sentence breaks. proper=standard title/sentence case. random_caps=no "
121
+ "consistent rule. mixed_i=author consistently writes 'i' in lowercase even "
122
+ "mid-sentence — common in Spanish chat where 'I' is not a standalone word "
123
+ "but the habit transfers from the native language's lowercase 'yo'.",
124
+ ),
125
+ "stylometric.emoji_usage": _cat(
126
+ "none", "occasional", "frequent", "exclusive",
127
+ notes="Rate of emoji use per message. exclusive=messages rarely contain text without "
128
+ "emoji. This captures tone and register — heavy emoji use in a criminal-market "
129
+ "context is a distinct style trait worth preserving.",
130
+ ),
131
+ "stylometric.emoji_placement": _cat(
132
+ "pre_punctuation", "post_punctuation", "no_punctuation", "mixed",
133
+ notes="Where emojis appear relative to sentence-ending punctuation. "
134
+ "pre_punctuation='Hola 😊.' post_punctuation='Hola. 😊' "
135
+ "Individual authors are strikingly consistent in this micro-habit.",
136
+ ),
137
+ "stylometric.message_length_class": _cat(
138
+ "short", "medium", "long", "paragraph",
139
+ notes="Median message length bucket: short=1-5 words, medium=6-20 words, "
140
+ "long=21-50 words, paragraph=>50 words. See also "
141
+ "stylometric.message_length_variance_class for the distribution shape.",
142
+ ),
143
+ "stylometric.message_length_variance_class": _cat(
144
+ "tight", "varied", "bimodal",
145
+ notes="Coefficient of variation of per-message word counts. Captures "
146
+ "DISTRIBUTION SHAPE that message_length_class collapses by "
147
+ "emitting only the median bucket. Two authors can share the same "
148
+ "median length but have wildly different variance: `tight` (CV<0.5) "
149
+ "= consistent (always 1-3 words), `varied` (0.5<=CV<1.5) = normal "
150
+ "mix, `bimodal` (CV>=1.5) = long-tail (mostly short with occasional "
151
+ "rants). Added in v0.2 after Rutify calibration found median-only "
152
+ "bucketing discarded most of the per-author variance signal.",
153
+ ),
154
+ "stylometric.linebreak_style": _cat(
155
+ "single_thought", "multi_line", "wall_of_text",
156
+ notes="Whether the author sends one complete thought per message or breaks a single "
157
+ "statement into multiple sequential short messages. multi_line=habitual "
158
+ "message-burst style (sends 3-5 short messages in rapid succession instead "
159
+ "of one composed message). wall_of_text=rarely uses line breaks, sends dense "
160
+ "blocks. Captures a stylistic rhythm that is hard to consciously alter.",
161
+ ),
162
+ "stylometric.typo_signature": _hash(notes="sha256 of canonical persistent-typo set"),
163
+ "stylometric.function_word_distribution_top50": _hash(
164
+ notes="64-bit simhash over the 50-most-common Spanish function-word frequency "
165
+ "vector. Mosteller-Wallace gold standard for English long-form authorship; "
166
+ "EMPIRICALLY DOMAIN-FLAWED for Spanish chat-domain — calibrated 2026-05-02 "
167
+ "against the Rutify corpus showed within-author and cross-author Hamming "
168
+ "distance distributions overlap (within median 8 bits, cross median 10 "
169
+ "bits) so this primitive ALONE cannot discriminate authors in chat-style "
170
+ "short-message corpora. Engines should weight it low until paired with "
171
+ "the larger top-200 variant or composited with character n-gram and "
172
+ "distinctive-vocabulary signatures (see siblings below). Kept in v0 for "
173
+ "calibration grids and documentary purposes.",
174
+ ),
175
+ "stylometric.function_word_distribution_top200": _hash(
176
+ notes="64-bit simhash over the 200-most-common Spanish function-word frequency "
177
+ "vector. The wider list reaches into the long tail (rare-but-individual "
178
+ "function words like `tampoco`, `aunque`, `mientras`) that carry more "
179
+ "discriminating signal in short-message chat domains. NOT YET EMITTED by "
180
+ "the v0 prototype extractor; populated when v0.2 calibration is done.",
181
+ ),
182
+ "stylometric.character_ngram_simhash": _hash(
183
+ notes="64-bit simhash over a frequency vector of character n-grams (default "
184
+ "n=3) from the author's lowercased text corpus. ORTHOGONAL to "
185
+ "function-word distributions: captures punctuation tics, accent-"
186
+ "stripping habits, typo patterns, and idiom-fragment fingerprints "
187
+ "that survive paraphrase. Lowercases input so that capitalization "
188
+ "habits — already captured by stylometric.capitalization_habit — "
189
+ "do not double-count. Accents PRESERVED because accent-stripping is "
190
+ "itself a stylistic tic worth catching. Source label declares n size "
191
+ "(e.g. `#char3gram`, `#char4gram`).",
192
+ ),
193
+ "stylometric.distinctive_vocabulary_signature": _hash(
194
+ notes="64-bit simhash over a TF-IDF-weighted top-K rare-word vector. "
195
+ "COMPLEMENTARY to function-word distributions: where function_word_* "
196
+ "captures common-word *style*, this captures the author's distinctive "
197
+ "*lexicon* (the words this person uses that other authors in the same "
198
+ "corpus do NOT). Strong against context-shift because rare words are "
199
+ "where authorial choice lives. Requires the chat corpus for IDF "
200
+ "computation, performed once per extraction. Source label declares the "
201
+ "top-K size and corpus tag (e.g. `#tfidf-top50`).",
202
+ ),
203
+
204
+ # ── lexical.* (cognitive analog — 8) ──────────────────────────────────
205
+ "lexical.vocabulary_richness": _num(
206
+ min_val=0.0, max_val=1.0,
207
+ notes="Moving-Average Type-Token Ratio (MATTR) over a sliding window "
208
+ "(default 50 tokens). Volume-independent: each window contributes "
209
+ "its own unique/total ratio, the primitive's value is the mean. "
210
+ "Avoids the standard TTR bias where larger corpora mechanically "
211
+ "score lower. Source label declares the window size.",
212
+ ),
213
+ "lexical.slang_density": _num(min_val=0.0, max_val=1.0,
214
+ notes="rate per message; locale-tuned slang corpus"),
215
+ "lexical.code_switching_rate": _num(min_val=0.0, max_val=1.0,
216
+ notes="switches per N tokens; Solorio & Liu metric"),
217
+ "lexical.code_switching_matrix_language": _str(notes="BCP-47 of dominant language"),
218
+ "lexical.code_switching_embedded_languages": _array(ValueKind.FREE_STRING,
219
+ notes="BCP-47 list of non-matrix languages observed"),
220
+ "lexical.sentence_complexity_class": _cat(
221
+ "simple", "compound", "complex",
222
+ notes="Dominant clause structure. simple=single-clause messages (no conjunctions "
223
+ "or subordination). compound=two independent clauses joined by coordinating "
224
+ "conjunctions (pero, y, o, ni). complex=dependent clauses and subordination "
225
+ "(aunque, porque, cuando, que + verb). Reflects education level and "
226
+ "cognitive investment in message composition.",
227
+ ),
228
+ "lexical.question_formation_style": _cat(
229
+ "punctuation_only", "lexical", "formal",
230
+ notes="How questions are formed. punctuation_only=question mark appended without "
231
+ "interrogative words ('¿Cuánto?' or 'Mañana?') — very common in Spanish "
232
+ "chat. lexical=explicit interrogatives (¿qué, cómo, cuándo, dónde). "
233
+ "formal=inverted subject-verb order or formal register ('¿Podría usted...'). "
234
+ "Captures register and education level.",
235
+ ),
236
+ "lexical.imperative_style": _cat(
237
+ "informal_directive", "formal_directive", "polite",
238
+ notes="How commands and requests are framed. informal_directive=tú/vos imperative "
239
+ "('dame', 'hazlo', 'mándame'). formal_directive=usted imperative "
240
+ "('hágame el favor', 'envíeme'). polite=conditional or modal softening "
241
+ "('¿podría...?', 'me gustaría...'). Stable per-author trait in criminal "
242
+ "market contexts where hierarchical and peer relationships are expressed "
243
+ "through register choice.",
244
+ ),
245
+
246
+ # ── temporal_evolution.* (lifecycle / change-over-time — 1) ───────────
247
+ "temporal_evolution.lifecycle_phase": _cat(
248
+ "arrival_burst", "stable_member", "fluctuating_member",
249
+ "inflection_member", "declining_member", "unknown",
250
+ notes="Auto-classified lifecycle stage derived from windowed within-"
251
+ "corpus analysis. arrival_burst: tenure < 24hr with first-window "
252
+ "volume dominating later windows and high inter-window drift "
253
+ "(empirically validated 2026-05-03 against OxPayload's first 12 "
254
+ "hours on Rutify). stable_member: low drift between consecutive "
255
+ "windows across the whole tenure. fluctuating_member (added v0.3): "
256
+ "tenure ≥ 24hr with median drift in [stable_max, inflection_min) "
257
+ "and no single window crossing inflection_min — established noisy "
258
+ "regulars who don't fit clean stable/inflection classes (e.g. "
259
+ "labelled admin lamarabitch, formerly classified unknown). "
260
+ "inflection_member: long-tenure actor whose drift spikes in at "
261
+ "least one window-pair (a real behavioral shift mid-corpus). "
262
+ "declining_member: monotonically decreasing per-window message "
263
+ "counts. unknown: insufficient windowed data for classification. "
264
+ "Window size adapts to tenure: <24hr → 2h windows, <7d → 12h, "
265
+ "<30d → 1d, otherwise 7d.",
266
+ ),
267
+
268
+ # ── network.* (governance/role-shape signals — 2, added v0.3) ─────────
269
+ "network.is_likely_bot": _cat(
270
+ "likely_bot", "not_bot", "unknown",
271
+ notes="Heuristic bot detector composited from existing primitives. "
272
+ "Classifies as likely_bot when conversation_initiation_rate ≥ 0.95 "
273
+ "AND attention_pattern = broadcast AND vocabulary_richness < 0.65. "
274
+ "Empirically validated 2026-05-03 against the tdl-labeled Rutify "
275
+ "bot SangMata_beta_bot (correctly caught) vs 11 high-volume humans "
276
+ "in the same corpus (none false-positive). NOT a verdict — engines "
277
+ "should treat as a candidate signal, especially since low-volume "
278
+ "bots (e.g. QuotLyBot with 9 messages) sit below the fingerprint "
279
+ "threshold and emit nothing here. Source label declares the "
280
+ "heuristic version (e.g. #bot-heuristic-v1).",
281
+ ),
282
+ "network.governance_role_signal": _cat(
283
+ "admin_pattern", "responder_pattern", "regular", "bot_pattern", "unknown",
284
+ notes="Heuristic role-shape composited from interaction primitives + "
285
+ "lifecycle_phase. admin_pattern: init_rate ≥ 0.80 AND attn = "
286
+ "reciprocal AND non-bot AND not arrival_burst. responder_pattern: "
287
+ "init_rate ≤ 0.45 AND attn = reciprocal. bot_pattern: matches "
288
+ "network.is_likely_bot likely_bot. regular: everything else above "
289
+ "the volume threshold. Empirically caught all 4 high-volume "
290
+ "tdl-labeled Rutify admins, sebaImlI as responder, "
291
+ "SangMata_beta_bot as bot, OxPayload/bopxcx as regular (their "
292
+ "arrival_burst lifecycle overrides the admin-shaped init_rate). "
293
+ "NOT a ground-truth admin label — kkaxlazer matches admin_pattern "
294
+ "while not formally admin, but the 2026-05-03 reply-graph cohort "
295
+ "analysis showed they're operationally embedded in the admin "
296
+ "layer (4/4 cohort signal with the top admin), so the heuristic "
297
+ "is doing the right thing.",
298
+ ),
299
+
300
+ # ── interaction.* (temporal analog — 6) ───────────────────────────────
301
+ "interaction.response_latency_class": _cat(
302
+ "immediate", "fast", "normal", "slow", "sporadic",
303
+ notes="How quickly the actor responds to messages directed at them. "
304
+ "immediate=<30s (suggests active monitoring or automated response). "
305
+ "fast=30s-5min. normal=5-60min (typical async chat). slow=1-24hr. "
306
+ "sporadic=no consistent response latency — appears and disappears.",
307
+ ),
308
+ "interaction.conversation_initiation_rate": _num(min_val=0.0, max_val=1.0,
309
+ notes="thread-starting messages / total"),
310
+ "interaction.message_burst_rate": _cat(
311
+ "single", "occasional", "habitual",
312
+ notes="Whether the actor sends multiple messages in rapid sequence within a "
313
+ "conversation turn. habitual=almost always bursts (sends 3+ messages "
314
+ "before any reply). single=almost always one message per turn. Tied to "
315
+ "stylometric.linebreak_style multi_line.",
316
+ ),
317
+ "interaction.active_hours_class": _str(notes="UTC active-hours window summary"),
318
+ "interaction.session_duration_class": _cat("short", "medium", "long", "marathon",
319
+ notes="REUSED enum from BEHAVE-SHELL temporal.session_duration"),
320
+ "interaction.attention_pattern": _cat("broadcast", "focused", "reciprocal",
321
+ notes="from reply-graph centrality"),
322
+
323
+ # ── content.* (operational analog — 6, EXPERIMENTAL) ──────────────────
324
+ "content.role_signal": _cat("admin", "seller", "buyer", "lurker", "newbie",
325
+ notes="EXPERIMENTAL — locale-tuned role-vocabulary classifier; "
326
+ "may be moved to a separate IOC/keyword-detection layer "
327
+ "once tested against the Rutify corpus"),
328
+ "content.transactional_language": _num(min_val=0.0, max_val=1.0,
329
+ notes="EXPERIMENTAL — rate of transactional terms; "
330
+ "locale-specific, brittle to vocabulary drift"),
331
+ "content.opsec_awareness": _num(min_val=0.0, max_val=1.0,
332
+ notes="EXPERIMENTAL — rate of security-conscious phrases; "
333
+ "HIGH FALSE-POSITIVE RISK on casual conversation about "
334
+ "deleting files / messages"),
335
+ "content.targeting_language": _array(ValueKind.FREE_STRING,
336
+ notes="EXPERIMENTAL — IOC-shaped target patterns "
337
+ "(bank names, government portals, RUT ranges, etc); "
338
+ "consider moving to dedicated IOC layer"),
339
+ "content.boasting_pattern": _cat("none", "occasional", "frequent",
340
+ notes="EXPERIMENTAL — success-claim regex; corpus-dependent"),
341
+ "content.conflict_style": _cat("aggressive", "defusing", "appellate",
342
+ notes="EXPERIMENTAL — dispute-tone classifier; needs "
343
+ "labelled training data"),
344
+ }
345
+
346
+
347
+ def is_known(primitive: str) -> bool:
348
+ return primitive in PRIMITIVE_REGISTRY
349
+
350
+
351
+ def get(primitive: str) -> ValueTypeSpec:
352
+ """Return the value-type spec for *primitive*; raise KeyError if unknown."""
353
+ return PRIMITIVE_REGISTRY[primitive]
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: behave-text
3
+ Version: 0.1.0
4
+ Summary: BEHAVE-TEXT — text/messaging-domain behavioral observation registry, layered on behave-core
5
+ Author: ANTI
6
+ License: GPL-3.0-or-later
7
+ Project-URL: Source, https://git.resacachile.cl/anti/BEHAVE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: pydantic>=2.6
10
+ Requires-Dist: behave-core>=0.1.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == "dev"
13
+ Requires-Dist: pytest-cov; extra == "dev"
14
+ Requires-Dist: ruff; extra == "dev"
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ behave_text/__init__.py
4
+ behave_text.egg-info/PKG-INFO
5
+ behave_text.egg-info/SOURCES.txt
6
+ behave_text.egg-info/dependency_links.txt
7
+ behave_text.egg-info/requires.txt
8
+ behave_text.egg-info/top_level.txt
9
+ behave_text/spec/__init__.py
10
+ behave_text/spec/envelope.py
11
+ behave_text/spec/primitives.py
12
+ tests/test_primitives.py
@@ -0,0 +1,7 @@
1
+ pydantic>=2.6
2
+ behave-core>=0.1.0
3
+
4
+ [dev]
5
+ pytest>=8
6
+ pytest-cov
7
+ ruff
@@ -0,0 +1 @@
1
+ behave_text
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "behave-text"
7
+ version = "0.1.0"
8
+ description = "BEHAVE-TEXT — text/messaging-domain behavioral observation registry, layered on behave-core"
9
+ requires-python = ">=3.11"
10
+ license = { text = "GPL-3.0-or-later" }
11
+ authors = [{ name = "ANTI" }]
12
+ dependencies = ["pydantic>=2.6", "behave-core>=0.1.0"]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8", "pytest-cov", "ruff"]
16
+
17
+ [project.urls]
18
+ "Source" = "https://git.resacachile.cl/anti/BEHAVE"
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["behave_text*"]
22
+
23
+ [tool.ruff]
24
+ line-length = 100
25
+ target-version = "py311"
26
+
27
+ [tool.ruff.lint]
28
+ select = ["E", "F", "I", "B", "UP"]
29
+ ignore = ["E501"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ addopts = "-q --import-mode=importlib"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,101 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Registry coverage tests for BEHAVE-TEXT.
3
+
4
+ Asserts that every primitive listed in scratchpad.md's tables has exactly one
5
+ entry in PRIMITIVE_REGISTRY. Drift-detector — failing this test means
6
+ scratchpad.md and the registry have diverged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from behave_text.spec import PRIMITIVE_REGISTRY, ValueKind
15
+
16
+ # Primitive paths expected by scratchpad.md (hand-extracted; v0).
17
+ EXPECTED_PRIMITIVES = {
18
+ # stylometric.* (motor analog — 8)
19
+ "stylometric.punctuation_style",
20
+ "stylometric.capitalization_habit",
21
+ "stylometric.emoji_usage",
22
+ "stylometric.emoji_placement",
23
+ "stylometric.message_length_class",
24
+ "stylometric.message_length_variance_class",
25
+ "stylometric.linebreak_style",
26
+ "stylometric.typo_signature",
27
+ "stylometric.function_word_distribution_top50",
28
+ "stylometric.function_word_distribution_top200",
29
+ "stylometric.character_ngram_simhash",
30
+ "stylometric.distinctive_vocabulary_signature",
31
+ # lexical.* (cognitive analog — 8)
32
+ "lexical.vocabulary_richness",
33
+ "lexical.slang_density",
34
+ "lexical.code_switching_rate",
35
+ "lexical.code_switching_matrix_language",
36
+ "lexical.code_switching_embedded_languages",
37
+ "lexical.sentence_complexity_class",
38
+ "lexical.question_formation_style",
39
+ "lexical.imperative_style",
40
+ # temporal_evolution.* (lifecycle/change-over-time — 1, added v0.2)
41
+ "temporal_evolution.lifecycle_phase",
42
+ # network.* (governance/role-shape — 2, added v0.3)
43
+ "network.is_likely_bot",
44
+ "network.governance_role_signal",
45
+ # interaction.* (temporal analog — 6)
46
+ "interaction.response_latency_class",
47
+ "interaction.conversation_initiation_rate",
48
+ "interaction.message_burst_rate",
49
+ "interaction.active_hours_class",
50
+ "interaction.session_duration_class",
51
+ "interaction.attention_pattern",
52
+ # content.* (operational analog — 6, EXPERIMENTAL)
53
+ "content.role_signal",
54
+ "content.transactional_language",
55
+ "content.opsec_awareness",
56
+ "content.targeting_language",
57
+ "content.boasting_pattern",
58
+ "content.conflict_style",
59
+ }
60
+
61
+
62
+ def test_registry_covers_expected_primitives_exactly():
63
+ registry_keys = set(PRIMITIVE_REGISTRY.keys())
64
+ missing = EXPECTED_PRIMITIVES - registry_keys
65
+ extra = registry_keys - EXPECTED_PRIMITIVES
66
+ assert not missing, f"registry missing: {sorted(missing)}"
67
+ assert not extra, f"registry has unexpected entries: {sorted(extra)}"
68
+
69
+
70
+ def test_every_primitive_has_a_valid_spec():
71
+ for primitive, spec in PRIMITIVE_REGISTRY.items():
72
+ if spec.kind is ValueKind.CATEGORICAL:
73
+ assert spec.allowed, f"{primitive}: categorical must define `allowed`"
74
+ assert all(isinstance(v, str) for v in spec.allowed)
75
+ elif spec.kind is ValueKind.ARRAY:
76
+ assert spec.array_of is not None, f"{primitive}: array must define `array_of`"
77
+ assert spec.array_of is not ValueKind.ARRAY, (
78
+ f"{primitive}: nested arrays not supported in v0"
79
+ )
80
+
81
+
82
+ def test_primitive_paths_are_dotted_lowercase():
83
+ pattern = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
84
+ for primitive in PRIMITIVE_REGISTRY:
85
+ assert pattern.match(primitive), f"malformed primitive path: {primitive!r}"
86
+
87
+
88
+ def test_experimental_primitives_are_in_content_layer_only():
89
+ """`status: experimental` should be confined to content.* in v0."""
90
+ for primitive, spec in PRIMITIVE_REGISTRY.items():
91
+ if spec.notes and "EXPERIMENTAL" in spec.notes:
92
+ assert primitive.startswith("content."), (
93
+ f"{primitive}: EXPERIMENTAL flag should only appear in content.* layer in v0"
94
+ )
95
+
96
+
97
+ def test_topic_namespace_uses_actor_not_attacker():
98
+ """The text-domain topic prefix must be `actor.*`, not `attacker.*`."""
99
+ from behave_text.spec import TOPIC_PREFIX, event_topic_for
100
+ assert TOPIC_PREFIX == "actor.observation.text"
101
+ assert event_topic_for("stylometric.emoji_usage") == "actor.observation.text.stylometric.emoji_usage"