Skip to content

feat: Limen-Neural shortlist + limen-axon-encoder-v0 trajectories - #16

Merged
rmems merged 8 commits into
mainfrom
feat/limen-axon-encoder-v0
Jul 31, 2026
Merged

feat: Limen-Neural shortlist + limen-axon-encoder-v0 trajectories#16
rmems merged 8 commits into
mainfrom
feat/limen-axon-encoder-v0

Conversation

@rmems

@rmems rmems commented Jul 24, 2026

Copy link
Copy Markdown
Owner

User description

Summary

First Limen-Neural extract sprint after PR #12 pipeline land.

Closes #13 · Closes #15

Org owner is rmems (public history only in this forge).

What landed

Area Paths
Docs docs/source-repos.md (Limen-Neural section)
Cards datasets/cards/limen-axon-encoder-v0.json, markdown card
Dataset datasets/jsonl/limen-axon-encoder-v0.jsonl (3 lines, ~210 KB)
Manifest datasets/manifests/limen-axon-encoder-v0.manifest.json
STATUS Updated sprint notes + roadmap

Trajectories

PR training_use quality
#37 review-to-patch 0.95
#50 repair 0.95
#41 review-to-patch 0.95

Safety

  • Collector GET only; raw under gitignored datasets/raw/Limen-Neural_axon-encoder/
  • validate_jsonl --strict-policy clean
  • No secrets / home paths in JSONL sample

Verification

ruff check scripts/
pytest -q
python scripts/validate_jsonl.py --strict-policy datasets/jsonl/*.jsonl

Not in this PR

— Grok Build Agent: Grok 4.5


CodeAnt-AI Description

Add the first Limen-Neural shortlist and extract three axon-encoder trajectories

What Changed

  • Added a new Limen-Neural section to the source repo list, including the first Wave A target repo shortlist
  • Added a new dataset card, JSONL extract, and manifest for three public axon-encoder PRs
  • Updated sprint status notes to reflect the new extract and remaining follow-up work

Impact

✅ Easier discovery of Limen-Neural source repos
✅ Three validated PR trajectories ready for training
✅ Clearer sprint status and dataset tracking

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Document Limen-Neural Wave A in source-repos and extract three high-signal
axon-encoder PRs (#37, #50, #41) into schema-valid JSONL with cards and
manifest. Raw collection stays gitignored. Owner of Limen-Neural is rmems;
public history only.

Closes #13
Closes #15

Authored by Grok Build Agent: Grok 4.5.
@codeant-ai

codeant-ai Bot commented Jul 24, 2026

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Incremental review completed 3c6fe02 Jul 25, 2026 · 01:58 02:01
✅ Reviewed your PR 8c311e9 Jul 24, 2026 · 00:19 00:24

@codeant-ai

codeant-ai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d44376ea-222c-473f-a559-47d07c89781f

📥 Commits

Reviewing files that changed from the base of the PR and between 58553da and 9ebd675.

📒 Files selected for processing (8)
  • STATUS.md
  • datasets/cards/limen-axon-encoder-trajectories-v0.md
  • datasets/cards/limen-axon-encoder-v0.json
  • datasets/jsonl/limen-axon-encoder-v0.jsonl
  • scripts/lib/bots.py
  • scripts/lib/normalize.py
  • tests/test_bots_and_secrets.py
  • tests/test_collect_and_normalize.py
📜 Recent review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: cubic · AI code reviewer
  • GitHub Check: Codacy Static Code Analysis
🧰 Additional context used
📓 Path-based instructions (3)
datasets/jsonl/**/*.jsonl

📄 CodeRabbit inference engine (AGENTS.md)

Validate JSONL files using validate_jsonl.py script against schema before committing

Files:

  • datasets/jsonl/limen-axon-encoder-v0.jsonl
scripts/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

Run ruff check scripts/ for linting before committing

Files:

  • scripts/lib/bots.py
  • scripts/lib/normalize.py
**/*test*.py

📄 CodeRabbit inference engine (AGENTS.md)

Run pytest -q to validate test suite before committing

Files:

  • tests/test_bots_and_secrets.py
  • tests/test_collect_and_normalize.py
🔇 Additional comments (7)
datasets/cards/limen-axon-encoder-trajectories-v0.md (1)

8-10: LGTM!

Also applies to: 20-24, 51-55

datasets/cards/limen-axon-encoder-v0.json (1)

7-7: LGTM!

datasets/jsonl/limen-axon-encoder-v0.jsonl (1)

1-3: 📐 Maintainability & Code Quality

Run the required JSONL validation.

Please validate this artifact before commit:

python scripts/validate_jsonl.py --strict-policy datasets/jsonl/limen-axon-encoder-v0.jsonl

Source: Coding guidelines

STATUS.md (1)

3-3: LGTM!

scripts/lib/bots.py (1)

68-76: 📐 Maintainability & Code Quality

Run the required script lint.

  • scripts/lib/bots.py#L68-L76: verify the updated regex declarations pass linting.
  • scripts/lib/normalize.py#L379-L430: verify the updated normalization logic passes linting.

Run ruff check scripts/ before committing. As per coding guidelines, “Run ruff check scripts/ for linting before committing.”

Source: Coding guidelines

scripts/lib/normalize.py (1)

379-379: LGTM!

Also applies to: 427-430, 605-679, 721-726

tests/test_bots_and_secrets.py (1)

68-82: 📐 Maintainability & Code Quality

Run the required test suite.

  • tests/test_bots_and_secrets.py#L68-L82: verify the bot-filter regression.
  • tests/test_collect_and_normalize.py#L438-L502: verify patch-noise filtering regressions.

Run pytest -q before committing. As per coding guidelines, “Run pytest -q to validate test suite before committing.”

Source: Coding guidelines


📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added the experimental Limen-Neural Axon Encoder v0 dataset, including curated trajectory records, metadata, and validation manifest.
    • Added dataset documentation covering provenance, scope, included changes, limitations, and collection guidance.
  • Improvements

    • Improved extraction quality by filtering automated review noise, secrets, temporary files, and non-actionable comments.
    • Added more accurate task and domain classification, issue-context selection, and validation details.
  • Documentation

    • Updated project status, extraction priorities, dataset quality tracking, remaining gaps, and verification guidance.

Walkthrough

The change documents Limen-Neural’s extraction scope, adds the limen-axon-encoder-v0 dataset artifacts, updates trajectory normalization and bot filtering, and adds tests plus verification documentation.

Changes

Limen-Neural trajectory extraction

Layer / File(s) Summary
Extraction scope and dataset contract
docs/source-repos.md, datasets/cards/*
Documents Limen-Neural priorities, the axon-encoder shortlist, exclusions, dataset metadata, limitations, provenance, and collection commands.
Trajectory records, manifest, and status
datasets/jsonl/limen-axon-encoder-v0.jsonl, datasets/manifests/limen-axon-encoder-v0.manifest.json, STATUS.md
Adds three structured PR records and a matching manifest, and updates quality notes, gaps, roadmap items, and verification commands.
Normalization heuristics and validation
scripts/lib/bots.py, scripts/lib/normalize.py, tests/test_bots_and_secrets.py, tests/test_collect_and_normalize.py
Adds heading matching, bot boilerplate removal, security task classification, per-PR domain overrides, revised issue-context extraction, review filtering, patch noise removal, and coverage for these behaviors.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: documentation, enhancement

Poem

A rabbit hops through JSONL bright,
Three Limen trails are packed just right.
Bots lose fluff, domains align,
Cards and manifests neatly shine.
Tests guard each burrowed dream.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: documenting the Limen-Neural shortlist and adding limen-axon-encoder-v0 trajectories.
Description check ✅ Passed The description matches the PR and covers the Limen-Neural shortlist, the three trajectories, and the related validation notes.
Linked Issues check ✅ Passed The changes satisfy the linked issue goals by adding the Limen section, the card, JSONL, manifest, and the three extracted PR trajectories.
Out of Scope Changes check ✅ Passed The normalization, bot-filtering, and test updates support the extraction pipeline and don't appear unrelated to the stated goals.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/limen-axon-encoder-v0

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codeant-ai codeant-ai Bot added the size:L This PR changes 100-499 lines, ignoring generated files label Jul 24, 2026
@linear-code

linear-code Bot commented Jul 24, 2026

Copy link
Copy Markdown

RM-173

RM-172

@amazon-q-developer amazon-q-developer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR successfully adds Limen-Neural Wave A shortlist documentation and the limen-axon-encoder-v0 dataset with 3 high-quality trajectories. The additions are well-structured and properly documented across all required files (STATUS.md, source-repos.md, cards, manifest, and JSONL).

All changes align with the established schema and project conventions. The documentation is comprehensive, metadata is accurate, and the extraction pipeline is clearly documented. No blocking issues were identified.


You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coderabbitai coderabbitai Bot added documentation Improvements or additions to documentation enhancement New feature or request labels Jul 24, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@datasets/jsonl/limen-axon-encoder-v0.jsonl`:
- Around line 1-3: Align domain metadata across all artifacts: in
datasets/jsonl/limen-axon-encoder-v0.jsonl lines 1-3, change PR `#41` to api and
PR `#50` to security; in datasets/cards/limen-axon-encoder-v0.json lines 9-13, add
security to the domain index; in
datasets/manifests/limen-axon-encoder-v0.manifest.json lines 27-52, mirror both
corrected domains and regenerate the checksum and byte metadata.

In `@docs/source-repos.md`:
- Around line 144-155: Clarify the “axon-encoder — first extract shortlist”
section by explicitly labeling its three-PR scope as a Wave A/v0 extraction
exception to the usual five-PR minimum. Keep the existing shortlist entries
unchanged and state the exception near the section status or heading.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c432d312-2e39-4c33-ae1a-6f678f7ca5d4

📥 Commits

Reviewing files that changed from the base of the PR and between 8493ce9 and 8c311e9.

📒 Files selected for processing (6)
  • STATUS.md
  • datasets/cards/limen-axon-encoder-trajectories-v0.md
  • datasets/cards/limen-axon-encoder-v0.json
  • datasets/jsonl/limen-axon-encoder-v0.jsonl
  • datasets/manifests/limen-axon-encoder-v0.manifest.json
  • docs/source-repos.md
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: Codacy Static Code Analysis
🧰 Additional context used
📓 Path-based instructions (1)
datasets/jsonl/**/*.jsonl

📄 CodeRabbit inference engine (AGENTS.md)

Validate JSONL files using validate_jsonl.py script against schema before committing

Files:

  • datasets/jsonl/limen-axon-encoder-v0.jsonl
🪛 LanguageTool
docs/source-repos.md

[grammar] ~142-~142: Ensure spelling is correct
Context: ...treaming features | | 4 | limbic-critic | Modulator semantics (not REUSE/CHANGELOG-only) | ### axon-encoder — first extract shortlist ...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (4)
docs/source-repos.md (1)

128-143: LGTM!

Also applies to: 156-171

datasets/cards/limen-axon-encoder-trajectories-v0.md (1)

1-46: LGTM!

STATUS.md (1)

3-49: LGTM!

datasets/manifests/limen-axon-encoder-v0.manifest.json (1)

7-10: 🗄️ Data Integrity & Integration

Manifest integrity matches the committed JSONL.

Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated
Comment thread docs/source-repos.md
Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c311e90b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated
Comment thread datasets/manifests/limen-axon-encoder-v0.manifest.json Outdated
Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated
Comment thread datasets/manifests/limen-axon-encoder-v0.manifest.json Outdated
- Per-PR domain_by_pr on card; #50 security, #41 api, #37 snn
- Title hint sec(…) → task_type security
- Prefer What changed over parenthetical (summary) headings
- Strip Gemini Code Assist sunset IMPORTANT boilerplate
- Document 3-PR Wave A exception; regenerate JSONL + manifest

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e6a531646e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/lib/bots.py Outdated
Comment thread scripts/lib/bots.py Outdated
Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated
- Match validation keywords as whole words outside parentheses
  (Local testing / CI validation) without (summary) parenthetical hits
- Only strip Gemini IMPORTANT product-lifecycle/sunset blocks
- Strip Codex "Useful? React with …" footers from review signals
- Regenerate limen-axon-encoder-v0 JSONL + manifest

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/lib/normalize.py (1)

319-324: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an inline-comment regression test for the new stripping order.

The supplied Codex signal test exercises reviews, not review_comments. Add a fixture with substantive inline text followed by the Codex footer and assert that the signal retains the substantive text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/lib/normalize.py` around lines 319 - 324, Add a regression fixture
for the review_comments/inline-comment path consumed by the normalization logic
around strip_bot_boilerplate and sanitize_text, containing substantive text
followed by the Codex footer. Assert that the resulting signal retains the
substantive text after boilerplate removal, covering the new stripping order
without changing the existing reviews test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/lib/bots.py`:
- Around line 103-119: The _drop_gemini_lifecycle filter is too broad and
removes legitimate IMPORTANT blocks based on generic lifecycle phrases. Update
its is_product_lifecycle logic to require a Gemini-specific marker or an exact
known Gemini boilerplate match before dropping blocks, while preserving removal
of the explicitly identified Gemini lifecycle notices and retaining actionable
non-Gemini guidance.

In `@scripts/lib/normalize.py`:
- Around line 143-151: Update the per-PR lookup in the domain normalization
logic to accept only values where v is a non-empty string after trimming. Return
the trimmed string for valid values, and ignore None, non-string, and
whitespace-only values instead of converting them with str(); keep the existing
key conversion and malformed-key handling unchanged.

---

Outside diff comments:
In `@scripts/lib/normalize.py`:
- Around line 319-324: Add a regression fixture for the
review_comments/inline-comment path consumed by the normalization logic around
strip_bot_boilerplate and sanitize_text, containing substantive text followed by
the Codex footer. Assert that the resulting signal retains the substantive text
after boilerplate removal, covering the new stripping order without changing the
existing reviews test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c45fcd36-7c12-4c0d-a368-9d376167f87f

📥 Commits

Reviewing files that changed from the base of the PR and between 8c311e9 and 2240a98.

📒 Files selected for processing (9)
  • datasets/cards/limen-axon-encoder-trajectories-v0.md
  • datasets/cards/limen-axon-encoder-v0.json
  • datasets/jsonl/limen-axon-encoder-v0.jsonl
  • datasets/manifests/limen-axon-encoder-v0.manifest.json
  • docs/source-repos.md
  • scripts/lib/bots.py
  • scripts/lib/normalize.py
  • tests/test_bots_and_secrets.py
  • tests/test_collect_and_normalize.py
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: Codacy Static Code Analysis
🧰 Additional context used
📓 Path-based instructions (3)
**/*test*.py

📄 CodeRabbit inference engine (AGENTS.md)

Run pytest -q to validate test suite before committing

Files:

  • tests/test_collect_and_normalize.py
  • tests/test_bots_and_secrets.py
datasets/jsonl/**/*.jsonl

📄 CodeRabbit inference engine (AGENTS.md)

Validate JSONL files using validate_jsonl.py script against schema before committing

Files:

  • datasets/jsonl/limen-axon-encoder-v0.jsonl
scripts/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

Run ruff check scripts/ for linting before committing

Files:

  • scripts/lib/normalize.py
  • scripts/lib/bots.py
🪛 ast-grep (0.44.1)
scripts/lib/bots.py

[warning] 162-162: Regex pattern passed to re is built from a non-literal (variable, call, concatenation, or f-string) value. If that value is attacker-controlled it can introduce a malicious pattern with catastrophic backtracking (ReDoS). Use a hardcoded literal pattern, or validate/escape untrusted input with re.escape() and bound the regex complexity before compiling.
Context: re.search(rf"\b{re.escape(kw)}\b", without_parens)
Note: [CWE-1333] Inefficient Regular Expression Complexity.

(redos-non-literal-regex-python)

🔇 Additional comments (14)
docs/source-repos.md (1)

128-143: LGTM!

Also applies to: 144-160, 161-169, 170-178

datasets/cards/limen-axon-encoder-trajectories-v0.md (1)

1-10: LGTM!

Also applies to: 11-24, 25-26, 27-33, 34-48

datasets/cards/limen-axon-encoder-v0.json (1)

10-31: LGTM!

datasets/jsonl/limen-axon-encoder-v0.jsonl (1)

1-3: LGTM!

datasets/manifests/limen-axon-encoder-v0.manifest.json (1)

9-10: LGTM!

Also applies to: 27-38, 41-52

scripts/lib/bots.py (4)

68-73: LGTM!


140-176: LGTM!


68-176: 📐 Maintainability & Code Quality

Run the required scripts lint check before committing.

The modified script files require the same repository-wide lint command.

  • scripts/lib/bots.py#L68-L176: run ruff check scripts/.
  • scripts/lib/normalize.py#L97-L319: run ruff check scripts/.

As per path instructions, files matching scripts/**/*.py must run ruff check scripts/ for linting before committing.

Source: Path instructions


74-76: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Match only the footer line, not the entire remaining document.

Because this pattern is unanchored at the start and ends at EOF, a matching Useful? React with ... line can delete all subsequent substantive markdown. Anchor it to a markdown line and replace only that line.

Proposed fix
-    r"(?is)\s*Useful\?\s*React with\s*[^\n]*$",
+    r"(?im)^[ \t]*Useful\?[ \t]*React with[^\n]*$",
			> Likely an incorrect or invalid review comment.
scripts/lib/normalize.py (2)

97-98: LGTM!


255-267: LGTM!

tests/test_bots_and_secrets.py (2)

11-31: LGTM!

Also applies to: 34-67


11-67: 📐 Maintainability & Code Quality

Run the required test suite before committing.

The modified test files require the same repository-wide test command.

  • tests/test_bots_and_secrets.py#L11-L67: run pytest -q.
  • tests/test_collect_and_normalize.py#L320-L336: run pytest -q.

As per path instructions, files matching **/*test*.py must run pytest -q to validate the test suite before committing.

Source: Path instructions

tests/test_collect_and_normalize.py (1)

320-336: 📐 Maintainability & Code Quality

Run the required test suite before committing.

Please run pytest -q and address any failures.

As per path instructions, files matching **/*test*.py must run pytest -q to validate the test suite before committing.

Source: Path instructions

Comment thread scripts/lib/bots.py Outdated
Comment thread scripts/lib/normalize.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2240a98a20

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/lib/bots.py Outdated
Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated
Comment thread scripts/lib/bots.py Outdated
Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated
Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated
- Gemini IMPORTANT strip requires Gemini product markers (keep API sunsets)
- domain_by_pr accepts only non-empty strings
- Drop bare Fixed/Addressed-in-SHA review acks from signals
- Omit remotes.txt (and .env) hunks from curated patches
- Regenerate limen-axon-encoder-v0 JSONL + manifest

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7ec787812

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/lib/normalize.py Outdated
Comment thread datasets/jsonl/limen-axon-encoder-v0.jsonl Outdated
- Match noise file hunks by parsed a/b basenames (keep .env.example)
- Omit remotes.txt/.env from before_context changed-files list
- Regenerate limen-axon-encoder-v0 JSONL + manifest
@codeant-ai

codeant-ai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c6fe02db9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/lib/normalize.py Outdated
Parse quoted a/b paths (octal escapes) so remotes.txt under non-ASCII
dirs is dropped; keeps .env.example basename matching correct.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58553da9be

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/lib/normalize.py
Comment thread scripts/lib/normalize.py
Comment thread scripts/lib/normalize.py Outdated
Comment thread scripts/lib/bots.py
…uotes

- Filter remotes.txt/.env from empty-patch changed-files fallback
- Parse unquoted diff --git paths without mid-path space splits
- Case-insensitive noise prefilter so .ENV / Remotes.txt are dropped
- End Gemini IMPORTANT block matches at blank lines (keep next signal)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 71cb11914f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/lib/normalize.py
]
# Unquoted: split only at the a/... → b/... boundary so paths may contain spaces.
if rest.startswith("a/"):
sep = rest.find(" b/")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse the actual diff path boundary

Fresh evidence after the spaced-path fix is that an unquoted old path can itself contain b/: Git emits a rename from dir b/.env to safe.txt as diff --git a/dir b/.env b/safe.txt, but this find splits at the embedded directory boundary and parses the paths as dir and .env b/safe.txt. Neither resulting basename is .env, so the local-config diff—including any simultaneous content changes—is retained; parse the real old/new boundary or inspect the subsequent path headers before filtering.

AGENTS.md reference: AGENTS.md:L21-L21

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="datasets/jsonl/limen-axon-encoder-v0.jsonl">

<violation number="1">
P2: Validation evidence for PRs #37 and #50 is silently cut mid-token, so consumers cannot distinguish a complete passing report from partial text. Preserve a complete summary or append an explicit truncation marker when applying the 1,500-character limit.</violation>
</file>

<file name="scripts/lib/bots.py">

<violation number="1" location="scripts/lib/bots.py:166">
P3: Validation under a common plural heading such as `## Test plans` is no longer collected, so a body-only passing test record can become “No structured validation evidence collected.” Consider recognizing plural title-token forms explicitly while retaining the parenthetical/substring safeguards.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread scripts/lib/normalize.py Outdated
@@ -0,0 +1,3 @@
{"id":"Limen-Neural-axon-encoder-37","repo":"Limen-Neural/axon-encoder","pr_number":37,"source_urls":["https://github.com/Limen-Neural/axon-encoder/pull/37","https://github.com/Limen-Neural/axon-encoder/issues/26"],"language":"Rust","domain":"snn","task_type":"feature","before_context":"PR title: feat(modulators): implement dynamic neuromodulator gain curves\nChanged files (20): .github/workflows/qodana_code_quality.yml, .gitignore, Cargo.lock, Cargo.toml, benches/allocations.rs, benches/encoders.rs, qodana.yaml, src/encoder.rs, src/encoders/delta.rs, src/encoders/derivative.rs, src/encoders/latency.rs, src/encoders/mod.rs, src/encoders/phase.rs, src/encoders/population.rs, src/encoders/predictive.rs, src/encoders/rate.rs, src/encoders/temporal.rs, src/lib.rs, src/modulators.rs, tests/serde_tests.rs\nMotivation/summary:\n* add public gain-curve and gain-composition types in `modulators.rs`\n* add opt-in neuromodulated encode helpers for rate, delta, temporal, predictive, and population encoders\n* add focused scaling tests plus serde coverage for the new modulation API","patch":"# Truncated unified diff for training (full raw under datasets/raw/; 115359 bytes, 20 files)\n# added: .github/workflows/qodana_code_quality.yml (+40/-0)\n# modified: .gitignore (+32/-7)\n# modified: Cargo.lock (+110/-66)\n# modified: Cargo.toml (+3/-4)\n# modified: benches/allocations.rs (+4/-4)\n# modified: benches/encoders.rs (+2/-1)\n# added: qodana.yaml (+47/-0)\n# modified: src/encoder.rs (+18/-4)\n# modified: src/encoders/delta.rs (+125/-5)\n# modified: src/encoders/derivative.rs (+12/-0)\n# modified: src/encoders/latency.rs (+203/-34)\n# modified: src/encoders/mod.rs (+3/-1)\n# added: src/encoders/phase.rs (+426/-0)\n# modified: src/encoders/population.rs (+200/-14)\n# modified: src/encoders/predictive.rs (+184/-26)\n# modified: src/encoders/rate.rs (+123/-10)\n# modified: src/encoders/temporal.rs (+127/-25)\n# modified: src/lib.rs (+26/-1)\n# modified: src/modulators.rs (+481/-7)\n# modified: tests/serde_tests.rs (+83/-1)\n\ndiff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml\nnew file mode 100644\nindex 00000000..7a01edaf\n--- /dev/null\n+++ b/.github/workflows/qodana_code_quality.yml\n@@ -0,0 +1,40 @@\n+#-------------------------------------------------------------------------------#\n+# Discover additional configuration options in our documentation #\n+# https://www.jetbrains.com/help/qodana/github.html #\n+#-------------------------------------------------------------------------------#\n+\n+name: Qodana\n+on:\n+ workflow_dispatch:\n+ pull_request:\n+ push:\n+ branches:\n+ - main\n+ - feat/26-neuromod-gain-curves\n+\n+jobs:\n+ qodana:\n+ runs-on: ubuntu-latest\n+ permissions:\n+ contents: write\n+ pull-requests: write\n+ checks: write\n+ steps:\n+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4\n+ with:\n+ ref: ${{ github.event.pull_request.head.sha }}\n+ fetch-depth: 0\n+ - name: 'Qodana Scan'\n+ uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891 # v2026.1\n+ env:\n+ QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}\n+ with:\n+ # When pr-mode is set to true, Qodana analyzes only the files that have been changed\n+ pr-mode: false\n+ use-caches: true\n+ post-pr-comment: true\n+ use-annotations: true\n+ # Upload Qodana results (SARIF, other artifacts, logs) as an artifact to the job\n+ upload-result: false\n+ # quick-fixes available in Ultimate and Ultimate Plus plans\n+ push-fixes: 'none'\n\\ No newline at end of file\ndiff --git a/.gitignore b/.gitignore\nindex 37791103..5d35b2cb 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -1,15 +1,40 @@\n+# AI Tool Local/Ephemeral (no clutter)\n+.kilo/worktrees/\n+.kilo/*.json\n+.kilocode/worktrees/\n+.devin/cache/\n+.mimocode/auth.json\n+.mimocode/plans/\n+.cursor/\n+.cursorignore\n+.worktrees/\n+\n # Compiled output\n-/target\n+/target/\n \n-# General editor and OS files\n-.vscode/\n+# IDE / editor\n .idea/\n+.vscode/\n *.swp\n *~\n-.DS_Store\n \n-# Log files\n+# Standard dev + your data\n+node_modules/\n+dist/\n+build/\n *.log\n+.env*\n+*.env\n+__pycache__/\n+.cache/\n+.DS_Store\n+neuromorphic_data/\n+remotes.txt\n \n-# Coverage reports\n-lcov.info\n+# Negations: Force-commit the good stuff\n+!.kilo/skills/**\n+!.kilo/tui.jsonc\n+!.devin/blueprint.yaml\n+!.mimocode/mimocode.jsonc\n+!.mimocode/AGENTS.md\n+!.kilocodeignore\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 6ca948c7..140584e3 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -11,6 +11,15 @@ dependencies = [\n \"memchr\",\n ]\n \n+[[package]]\n+name = \"alloca\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4\"\n+dependencies = [\n+ \"cc\",\n+]\n+\n [[package]]\n name = \"anes\"\n version = \"0.1.6\"\n@@ -31,7 +40,7 @@ checksum = \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\"\n \n [[package]]\n name = \"axon-encoder\"\n-version = \"0.3.0\"\n+version = \"0.4.0\"\n dependencies = [\n \"criterion\",\n \"serde\",\n@@ -50,6 +59,16 @@ version = \"0.3.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5\"\n \n+[[package]]\n+name = \"cc\"\n+version = \"1.2.67\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38\"\n+dependencies = [\n+ \"find-msvc-tools\",\n+ \"shlex\",\n+]\n+\n [[package]]\n name = \"cfg-if\"\n version = \"1.0.4\"\n@@ -110,25 +129,24 @@ checksum = \"c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9\"\n \n [[package]]\n name = \"criterion\"\n-version = \"0.5.1\"\n+version = \"0.8.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f\"\n+checksum = \"950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3\"\n dependencies = [\n+ \"alloca\",\n \"anes\",\n \"cast\",\n \"ciborium\",\n \"clap\",\n \"criterion-plot\",\n- \"is-terminal\",\n \"itertools\",\n \"num-traits\",\n- \"once_cell\",\n \"oorandom\",\n+ \"page_size\",\n \"plotters\",\n \"rayon\",\n \"regex\",\n \"serde\",\n- \"serde_derive\",\n \"serde_json\",\n \"tinytemplate\",\n \"walkdir\",\n@@ -136,9 +154,9 @@ dependencies = [\n \n [[package]]\n name = \"criterion-plot\"\n-version = \"0.5.0\"\n+version = \"0.8.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1\"\n+checksum = \"d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea\"\n dependencies = [\n \"cast\",\n \"itertools\",\n@@ -146,9 +164,9 @@ dependencies = [\n \n [[package]]\n name = \"crossbeam-deque\"\n-version = \"0.8.6\"\n+version = \"0.8.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51\"\n+checksum = \"5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb\"\n dependencies = [\n \"crossbeam-epoch\",\n \"crossbeam-utils\",\n@@ -156,18 +174,18 @@ dependencies = [\n \n [[package]]\n name = \"crossbeam-epoch\"\n-version = \"0.9.18\"\n+version = \"0.9.20\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e\"\n+checksum = \"2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f\"\n dependencies = [\n \"crossbeam-utils\",\n ]\n \n [[package]]\n name = \"crossbeam-utils\"\n-version = \"0.8.21\"\n+version = \"0.8.22\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28\"\n+checksum = \"61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17\"\n \n [[package]]\n name = \"crunchy\"\n@@ -181,6 +199,12 @@ version = \"1.16.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e\"\n \n+[[package]]\n+name = \"find-msvc-tools\"\n+version = \"0.1.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582\"\n+\n [[package]]\n name = \"futures-core\"\n version = \"0.3.32\"\n@@ -216,28 +240,11 @@ dependencies = [\n \"zerocopy\",\n ]\n \n-[[package]]\n-name = \"hermit-abi\"\n-version = \"0.5.2\"\n-source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c\"\n-\n-[[package]]\n-name = \"is-terminal\"\n-version = \"0.4.17\"\n-source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46\"\n-dependencies = [\n- \"hermit-abi\",\n- \"libc\",\n- \"windows-sys\",\n-]\n-\n [[package]]\n name = \"itertools\"\n-version = \"0.10.5\"\n+version = \"0.13.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473\"\n+checksum = \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\"\n dependencies = [\n \"either\",\n ]\n@@ -250,13 +257,12 @@ checksum = \"8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682\"\n \n [[package]]\n name = \"js-sys\"\n-version = \"0.3.99\"\n+version = \"0.3.103\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11\"\n+checksum = \"53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102\"\n dependencies = [\n \"cfg-if\",\n \"futures-util\",\n- \"once_cell\",\n \"wasm-bindgen\",\n ]\n \n@@ -268,9 +274,9 @@ checksum = \"68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66\"\n \n [[package]]\n name = \"memchr\"\n-version = \"2.8.1\"\n+version = \"2.8.3\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8\"\n+checksum = \"cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98\"\n \n [[package]]\n name = \"num-traits\"\n@@ -293,6 +299,16 @@ version = \"11.1.5\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e\"\n \n+[[package]]\n+name = \"page_size\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da\"\n+dependencies = [\n+ \"libc\",\n+ \"winapi\",\n+]\n+\n [[package]]\n name = \"pin-project-lite\"\n version = \"0.2.17\"\n@@ -338,9 +354,9 @@ dependencies = [\n \n [[package]]\n name = \"quote\"\n-version = \"1.0.45\"\n+version = \"1.0.46\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924\"\n+checksum = \"dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368\"\n dependencies = [\n \"proc-macro2\",\n ]\n@@ -367,9 +383,9 @@ dependencies = [\n \n [[package]]\n name = \"regex\"\n-version = \"1.12.3\"\n+version = \"1.13.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276\"\n+checksum = \"2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2\"\n dependencies = [\n \"aho-corasick\",\n \"memchr\",\n@@ -379,9 +395,9 @@ dependencies = [\n \n [[package]]\n name = \"regex-automata\"\n-version = \"0.4.14\"\n+version = \"0.4.15\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f\"\n+checksum = \"1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db\"\n dependencies = [\n \"aho-corasick\",\n \"memchr\",\n@@ -390,15 +406,15 @@ dependencies = [\n \n [[package]]\n name = \"regex-syntax\"\n-version = \"0.8.10\"\n+version = \"0.8.11\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a\"\n+checksum = \"d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4\"\n \n [[package]]\n name = \"rustversion\"\n-version = \"1.0.22\"\n+version = \"1.0.23\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d\"\n+checksum = \"cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f\"\n \n [[package]]\n name = \"same-file\"\n@@ -452,6 +468,12 @@ dependencies = [\n \"zmij\",\n ]\n \n+[[package]]\n+name = \"shlex\"\n+version = \"2.0.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba\"\n+\n [[package]]\n name = \"slab\"\n version = \"0.4.12\"\n@@ -460,9 +482,9 @@ checksum = \"0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5\"\n \n [[package]]\n name = \"syn\"\n-version = \"2.0.117\"\n+version = \"2.0.119\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99\"\n+checksum = \"872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297\"\n dependencies = [\n \"proc-macro2\",\n \"quote\",\n@@ -497,9 +519,9 @@ dependencies = [\n \n [[package]]\n name = \"wasm-bindgen\"\n-version = \"0.2.122\"\n+version = \"0.2.126\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409\"\n+checksum = \"4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4\"\n dependencies = [\n \"cfg-if\",\n \"once_cell\",\n@@ -510,9 +532,9 @@ dependencies = [\n \n [[package]]\n name = \"wasm-bindgen-macro\"\n-version = \"0.2.122\"\n+version = \"0.2.126\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6\"\n+checksum = \"167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1\"\n dependencies = [\n \"quote\",\n \"wasm-bindgen-macro-support\",\n@@ -520,9 +542,9 @@ dependencies = [\n \n [[package]]\n name = \"wasm-bindgen-macro-support\"\n-version = \"0.2.122\"\n+version = \"0.2.126\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e\"\n+checksum = \"f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e\"\n dependencies = [\n \"bumpalo\",\n \"proc-macro2\",\n@@ -533,23 +555,39 @@ dependencies = [\n \n [[package]]\n name = \"wasm-bindgen-shared\"\n-version = \"0.2.122\"\n+version = \"0.2.126\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437\"\n+checksum = \"dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24\"\n dependencies = [\n \"unicode-ident\",\n ]\n \n [[package]]\n name = \"web-sys\"\n-version = \"0.3.99\"\n+version = \"0.3.103\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436\"\n+checksum = \"8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141\"\n dependencies = [\n \"js-sys\",\n \"wasm-bindgen\",\n ]\n \n+[[package]]\n+name = \"winapi\"\n+version = \"0.3.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419\"\n+dependencies = [\n+ \"winapi-i686-pc-windows-gnu\",\n+ \"winapi-x86_64-pc-windows-gnu\",\n+]\n+\n+[[package]]\n+name = \"winapi-i686-pc-windows-gnu\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6\"\n+\n [[package]]\n name = \"winapi-util\"\n version = \"0.1.11\"\n@@ -559,6 +597,12 @@ dependencies = [\n \"windows-sys\",\n ]\n \n+[[package]]\n+name = \"winapi-x86_64-pc-windows-gnu\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f\"\n+\n [[package]]\n name = \"windows-link\"\n version = \"0.2.1\"\n@@ -576,18 +620,18 @@ dependencies = [\n \n [[package]]\n name = \"zerocopy\"\n-version = \"0.8.49\"\n+version = \"0.8.54\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b\"\n+checksum = \"b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19\"\n dependencies = [\n \"zerocopy-derive\",\n ]\n \n [[package]]\n name = \"zerocopy-derive\"\n-version = \"0.8.49\"\n+version = \"0.8.54\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e\"\n+checksum = \"e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5\"\n dependencies = [\n \"proc-macro2\",\n \"quote\",\n@@ -596,6 +640,6 @@ dependencies = [\n \n [[package]]\n name = \"zmij\"\n-version = \"1.0.21\"\n+version = \"1.0.23\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n-checksum = \"b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa\"\n+checksum = \"29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b\"\ndiff --git a/Cargo.toml b/Cargo.toml\nindex f360c439..45b486b3 100644\n--- a/Cargo.toml\n+++ b/Cargo.toml\n@@ -1,21 +1,20 @@\n [package]\n name = \"axon-encoder\"\n-version = \"0.3.0\"\n+version = \"0.4.0\"\n description = \"Flexible sensory encoding pipelines for spiking neural networks — rate, temporal, predictive, population, and neuromodulator-driven encoding for telemetry and cyber-physical data.\"\n repository = \"https://github.com/Limen-Neural/axon-encoder\"\n license = \"MIT OR Apache-2.0\"\n-edition = \"2021\"\n+edition = \"2024\"\n \n [dependencies]\n serde = { version = \"1.0\", features = [\"derive\"], optional = true }\n \n [dev-dependencies]\n-criterion = \"0.5\"\n+criterion = \"0.8.2\"\n serde_json = \"1.0\"\n \n [features]\n default = []\n-ndarray = []\n cuda = []\n serde = [\"dep:serde\"]\n \ndiff --git a/benches/allocations.rs b/benches/allocations.rs\nindex e95837c3..87a7b1e9 100644\n--- a/benches/allocations.rs\n+++ b/benches/allocations.rs\n@@ -21,7 +21,7 @@ static GLOBAL_ALLOCATOR: CountingAllocator = CountingAllocator;\n unsafe impl GlobalAlloc for CountingAllocator {\n unsafe fn alloc(&self, layout: Layout) -> *mut u8 {\n // Using SeqCst to ensure measurement boundaries are strictly respected.\n- let ptr = System.alloc(layout);\n+ let ptr = unsafe { System.alloc(layout) };\n if COUNTING_ENABLED.load(Ordering::SeqCst) && !ptr.is_null() {\n ALLOCATION_COUNT.fetch_add(1, Ordering::SeqCst);\n ALLOCATION_BYTES.fetch_add(layout.size(), Ordering::SeqCst);\n@@ -31,7 +31,7 @@ unsafe impl GlobalAlloc for CountingAllocator {\n \n unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {\n // Using SeqCst to ensure measurement boundaries are strictly respected.\n- let ptr = System.alloc_zeroed(layout);\n+ let ptr = unsafe { System.alloc_zeroed(layout) };\n if COUNTING_ENABLED.load(Ordering::SeqCst) && !ptr.is_null() {\n ALLOCATION_COUNT.fetch_add(1, Ordering::SeqCst);\n ALLOCATION_BYTES.fetch_add(layout.size(), Ordering::SeqCst);\n@@ -41,7 +41,7 @@ unsafe impl GlobalAlloc for CountingAllocator {\n \n unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {\n // Using SeqCst to ensure measurement boundaries are strictly respected.\n- let new_ptr = System.realloc(ptr, layout, new_size);\n+ let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };\n if COUNTING_ENABLED.load(Ordering::SeqCst) && !new_ptr.is_null() {\n // Only count the net growth to avoid double-counting existing allocations.\n ALLOCATION_COUNT.fetch_add(1, Ordering::SeqCst);\n@@ -52,7 +52,7 @@ unsafe impl GlobalAlloc for CountingAllocator {\n \n unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {\n // dealloc is not tracked to focus on net growth metrics during the measured operation.\n- System.dealloc(ptr, layout);\n+ unsafe { System.dealloc(ptr, layout) };\n }\n }\n \ndiff --git a/benches/encoders.rs b/benches/encoders.rs\nindex 617a446f..526d0e39 100644\n--- a/benches/encoders.rs\n+++ b/benches/encoders.rs\n@@ -2,7 +2,8 @@ use axon_encoder::encoders::{\n DeltaEncoder, PopulationEncoder, PredictiveEncoder, RateEncoder, TemporalEncoder,\n };\n use axon_encoder::prelude::*;\n-use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};\n+use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};\n+use std::hint::black_box;\n \n const SCALES: [usize; 3] = [256, 1024, 10_000];\n const POISSON_STEPS: [usize; 3] = [10, 100, 1000];\ndiff --git a/qodana.yaml b/qodana.yaml\nnew file mode 100644\nindex 00000000..38922c76\n--- /dev/null\n+++ b/qodana.yaml\n@@ -0,0 +1,47 @@\n+#-------------------------------------------------------------------------------#\n+# Qodana analysis is configured by qodana.yaml file #\n+# https://www.jetbrains.com/help/qodana/qodana-yaml.html #\n+#-------------------------------------------------------------------------------#\n+\n+#################################################################################\n+# WARNING: Do not store sensitive information in this file, #\n+# as its contents will be included in the Qodana report. #\n+#################################################################################\n+version: \"1.0\"\n+\n+#Specify inspection profile for code analysis\n+profile:\n+ name: qodana.starter\n+\n+#Enable inspections\n+#include:\n+# - name: <SomeEnabledInspectionId>\n+\n+#Disable inspections\n+#exclude:\n+# - name: <SomeDisabledInspectionId>\n+# paths:\n+# - <path/where/not/run/inspection>\n+\n+#Execute shell command before Qodana execution (Applied in CI/CD pipeline)\n+#bootstrap: sh ./prepare-qodana.sh\n+\n+#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)\n+#plugins:\n+# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)\n+\n+# Quality gate. Will fail the CI/CD pipeline if any condition is not met\n+# severityThresholds - configures maximum thresholds for different problem severities\n+# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code\n+# Code Coverage is available in Ultimate and Ultimate Plus plans\n+#failureConditions:\n+# severityThresholds:\n+# any: 15\n+# critical: 5\n+# testCoverageThresholds:\n+# fresh: 70\n+# total: 50\n+\n+#Specify Qodana Docker image for analysis (Applied in CI/CD pipeline)\n+# Concrete Rust linter image (replaces the qodana-<linter> template placeholder).\n+image: jetbrains/qodana-rust:2026.1-eap\ndiff --git a/src/encoder.rs b/src/encoder.rs\nindex 29cd73ae..cb363811 100644\n--- a/src/encoder.rs\n+++ b/src/encoder.rs\n@@ -163,6 +163,18 @@ mod tests {\n assert_eq!(state.membrane_potentials.len(), 5);\n assert!(state.membrane_potentials.iter().all(|&v| v == 0.0));\n }\n+\n+ #[cfg(feature = \"serde\")]\n+ #[test]\n+ fn test_embedding_rate_encoder_deserialize_rejects_too_many_channels() {\n+ let embeddings: Vec<f32> = vec![0.0; (u16::MAX as usize) + 2];\n+ let value = serde_json::json!({\n+ \"config\": {\"v_th\": 1.0},\n+ \"normalized_embeddings\": embeddings,\n+ });\n+ let res: Result<EmbeddingRateEncoder, _> = serde_json::from_value(value);\n+ assert!(res.is_err());\n+ }\n }\n \n #[cfg(test)]\n@@ -238,10 +250,12 @@ mod forward_coverage_tests {\n fn embedding_rate_encoder_handles_equal_embeddings() {\n let embeddings = vec![2.5, 2.5, 2.5];\n let encoder = EmbeddingRateEncoder::new(&embeddings, EmbeddingEncoderConfig { v_th: 0.5 });\n- assert!(encoder\n- .normalized_embeddings\n- .iter()\n- .all(|value| *value == 0.0));\n+ assert!(\n+ encoder\n+ .normalized_embeddings\n+ .iter()\n+ .all(|value| *value == 0.0)\n+ );\n \n let (output, next_state) = encoder.forward(&EncoderState::new_zeros(3));\n assert!(output.spikes.is_empty());\ndiff --git a/src/encoders/delta.rs b/src/encoders/delta.rs\nindex 73e51616..9b4dc007 100644\n--- a/src/encoders/delta.rs\n+++ b/src/encoders/delta.rs\n@@ -37,19 +37,27 @@ impl DeltaEncoder {\n threshold,\n }\n }\n-}\n \n-impl Encoder for DeltaEncoder {\n- fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ fn encode_with_threshold_scale(\n+ &mut self,\n+ input: &[f32],\n+ threshold_scale: f32,\n+ ) -> EncodedOutput {\n let mut output = EncodedOutput::new();\n+ let effective_threshold = (self.threshold * threshold_scale).max(0.0);\n+\n for (i, &value) in input.iter().enumerate() {\n if i >= self.last_values.len() {\n break;\n }\n+ let Ok(channel) = u16::try_from(i) else {\n+ // Remaining channels exceed u16::MAX; stop rather than wrap.\n+ break;\n+ };\n let delta = (value - self.last_values[i]).abs();\n- if delta > self.threshold {\n+ if delta > effective_threshold {\n output.spikes.push(SpikeEvent {\n- channel: u16::try_from(i).expect(\"channel index exceeds u16::MAX\"),\n+ channel,\n timestamp: 0,\n polarity: value > self.last_values[i],\n });\n@@ -59,6 +67,49 @@ impl Encoder for DeltaEncoder {\n output\n }\n \n+ /// Encode input using neuromodulator-driven gain curves.\n+ ///\n+ /// Evaluates `gain_curves` against the current `modulators` to produce\n+ /// an [`EncodingGains`], then uses the `threshold_scale` component to\n+ /// modulate the delta-change detection threshold. Values > 1.0 raise the\n+ /// effective threshold (less sensitive — larger changes required to spike);\n+ /// values in (0, 1) lower it (more sensitive).\n+ ///\n+ /// `NeuromodulatorGainCurves` composes per-modulator curves\n+ /// (dopamine, cortisol, acetylcholine, tempo) multiplicatively via\n+ /// [`EncodingGains::sanitize`]. Expected modulator range: any finite f32.\n+ /// Expected gain output range: `[MIN_GAIN_SCALE, MAX_GAIN_SCALE]`\n+ /// (0.0 to 10,000.0 after sanitization).\n+ pub fn encode_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_threshold_scale(input, gains.threshold_scale)\n+ }\n+\n+ /// Step-wise variant of [`encode_with_modulators`](Self::encode_with_modulators).\n+ ///\n+ /// Identical behavior — provided for API symmetry with the [`Encoder`] trait's\n+ /// `encode` / `encode_step` pair.\n+ pub fn encode_step_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_threshold_scale(input, gains.threshold_scale)\n+ }\n+}\n+\n+impl Encoder for DeltaEncoder {\n+ fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ self.encode_with_threshold_scale(input, 1.0)\n+ }\n+\n fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {\n let safe_input = if input.len() > self.last_values.len() {\n &input[..self.last_values.len()]\n@@ -135,4 +186,73 @@ mod tests {\n let spikes = encode_deltas_to_spikes(&deltas, threshold);\n assert_eq!(spikes, vec![false, false, true, true]);\n }\n+\n+ #[test]\n+ fn test_delta_encoder_modulators_reduce_threshold() {\n+ let mut encoder = DeltaEncoder::new(1.0, 1);\n+ let modulators = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+ let gain_curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ threshold: Some(GainCurve::new((0.0, 1.0), (1.0, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+\n+ assert!(encoder.encode(&[0.75]).spikes.is_empty());\n+ encoder.reset();\n+\n+ let modulated = encoder.encode_with_modulators(&[0.75], &modulators, &gain_curves);\n+ assert_eq!(modulated.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_delta_encoder_encode_step_with_modulators() {\n+ let mut encoder = DeltaEncoder::new(1.0, 1);\n+ let modulators = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+ let gain_curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ threshold: Some(GainCurve::new((0.0, 1.0), (1.0, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+\n+ assert!(\n+ encoder\n+ .encode_step_with_modulators(&[0.0], &modulators, &gain_curves)\n+ .spikes\n+ .is_empty()\n+ );\n+ let modulated = encoder.encode_step_with_modulators(&[0.75], &modulators, &gain_curves);\n+ assert_eq!(modulated.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_delta_encoder_step_shorter_input() {\n+ let mut encoder = DeltaEncoder::new(1.0, 2);\n+ let output = encoder.encode_step(&[2.0]);\n+ assert_eq!(output.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_delta_encoder_truncates_excess_channels() {\n+ let mut encoder = DeltaEncoder::new(1.0, 1);\n+ let output = encoder.encode(&[2.0, 3.0]);\n+ assert_eq!(output.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_delta_encoder_zero_threshold_scale_spikes_on_any_change() {\n+ let mut encoder = DeltaEncoder::new(1.0, 1);\n+ encoder.encode(&[0.0]);\n+ let output = encoder.encode_with_threshold_scale(&[0.01], 0.0);\n+ assert_eq!(output.spikes.len(), 1);\n+ }\n }\ndiff --git a/src/encoders/derivative.rs b/src/encoders/derivative.rs\nindex f34264c6..f068a234 100644\n--- a/src/encoders/derivative.rs\n+++ b/src/encoders/derivative.rs\n@@ -158,6 +158,18 @@ mod tests {\n let output = encoder.encode(&[2.0, 3.0]);\n assert_eq!(output.spikes.len(), 1); // Only channel 0 should be processed\n }\n+\n+ #[cfg(feature = \"serde\")]\n+ #[test]\n+ fn test_derivative_serde_rejects_too_many_channels() {\n+ let values: Vec<f32> = vec![0.0; (u16::MAX as usize) + 2];\n+ let value = serde_json::json!({\n+ \"last_values\": values.clone(),\n+ \"thresholds\": values,\n+ });\n+ let res: Result<DerivativeEncoder, _> = serde_json::from_value(value);\n+ assert!(res.is_err());\n+ }\n }\n \n #[cfg(test)]\ndiff --git a/src/encoders/latency.rs b/src/encoders/latency.rs\nindex 8811a5c3..f805039e 100644\n--- a/src/encoders/latency.rs\n+++ b/src/encoders/latency.rs\n@@ -8,52 +8,33 @@ use crate::prelude::*;\n /// possible spike at `max_latency`, and values above the range maximum map to\n /// timestamp `0`.\n #[derive(Clone, Debug, PartialEq)]\n-#[cfg_attr(feature = \"serde\", derive(serde::Serialize, serde::Deserialize))]\n-#[cfg_attr(feature = \"serde\", serde(try_from = \"LatencyEncoderRepr\"))]\n+#[cfg_attr(feature = \"serde\", derive(serde::Serialize))]\n pub struct LatencyEncoder {\n max_latency: u64,\n range: (f32, f32),\n }\n \n-#[cfg(feature = \"serde\")]\n-#[derive(serde::Deserialize)]\n-struct LatencyEncoderRepr {\n- max_latency: u64,\n- range: (f32, f32),\n-}\n-\n-#[cfg(feature = \"serde\")]\n-impl TryFrom<LatencyEncoderRepr> for LatencyEncoder {\n- type Error = String;\n-\n- fn try_from(r: LatencyEncoderRepr) -> Result<Self, String> {\n- if r.range.0.partial_cmp(&r.range.1) != Some(core::cmp::Ordering::Less) {\n- return Err(\"range min must be less than range max\".into());\n- }\n- Ok(Self {\n- max_latency: r.max_latency,\n- range: r.range,\n- })\n- }\n-}\n-\n impl LatencyEncoder {\n /// Creates a new `LatencyEncoder`.\n ///\n /// # Panics\n ///\n- /// Panics if `range.0 >= range.1`.\n+ /// Panics if `range.0 >= range.1` or if either bound is non-finite.\n pub fn new(max_latency: u64, range: (f32, f32)) -> Self {\n- if range.0.partial_cmp(&range.1) != Some(core::cmp::Ordering::Less) {\n- panic!(\"range min must be less than range max\");\n- }\n+ assert!(\n+ range.0.is_finite() && range.1.is_finite() && range.0 < range.1,\n+ \"range must be finite and min must be less than max\"\n+ );\n \n Self { max_latency, range }\n }\n \n- fn clamp_and_normalize(&self, value: f32) -> f32 {\n- let clamped = value.clamp(self.range.0, self.range.1);\n- (clamped - self.range.0) / (self.range.1 - self.range.0)\n+ fn normalize(&self, value: f32) -> f64 {\n+ // Use f64 to prevent overflow for valid f32 ranges (e.g., f32::MIN..f32::MAX).\n+ let clamped = value.clamp(self.range.0, self.range.1) as f64;\n+ let lo = self.range.0 as f64;\n+ let hi = self.range.1 as f64;\n+ (clamped - lo) / (hi - lo)\n }\n \n fn timestamp_for(&self, value: f32) -> u64 {\n@@ -64,9 +45,71 @@ impl LatencyEncoder {\n return self.max_latency;\n }\n \n- let normalized = self.clamp_and_normalize(value) as f64;\n+ let normalized = self.normalize(value);\n ((1.0 - normalized) * self.max_latency as f64).round() as u64\n }\n+\n+ fn timestamp_for_with_latency_scale(&self, value: f32, latency_scale: f32) -> u64 {\n+ let scaled_latency = ((self.max_latency as f64) * (latency_scale as f64)).round() as u64;\n+ if scaled_latency == 0 {\n+ return 0;\n+ }\n+ if value.is_nan() {\n+ return scaled_latency;\n+ }\n+\n+ let normalized = self.normalize(value);\n+ ((1.0 - normalized) * scaled_latency as f64).round() as u64\n+ }\n+\n+ /// Encode input using neuromodulator-driven gain curves.\n+ ///\n+ /// Evaluates `gain_curves` against the current `modulators` to produce\n+ /// an [`EncodingGains`], then uses the `latency_scale` component to\n+ /// modulate the maximum latency. Values > 1.0 increase max_latency\n+ /// (slower response); values in (0, 1) decrease it (faster response).\n+ pub fn encode_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_latency_scale(input, gains.latency_scale)\n+ }\n+\n+ /// Step-wise variant of [`encode_with_modulators`](Self::encode_with_modulators).\n+ ///\n+ /// Identical behavior — provided for API symmetry with the [`Encoder`] trait's\n+ /// `encode` / `encode_step` pair.\n+ pub fn encode_step_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_latency_scale(input, gains.latency_scale)\n+ }\n+\n+ fn encode_with_latency_scale(&mut self, input: &[f32], latency_scale: f32) -> EncodedOutput {\n+ let mut output = EncodedOutput::new();\n+ output.spikes.reserve(input.len());\n+\n+ for (channel, &value) in input.iter().enumerate() {\n+ let Ok(channel) = u16::try_from(channel) else {\n+ // Remaining channels exceed u16::MAX; stop rather than wrap.\n+ break;\n+ };\n+ output.spikes.push(SpikeEvent {\n+ channel,\n+ timestamp: self.timestamp_for_with_latency_scale(value, latency_scale),\n+ polarity: true,\n+ });\n+ }\n+\n+ output\n+ }\n }\n \n impl Encoder for LatencyEncoder {\n@@ -75,8 +118,12 @@ impl Encoder for LatencyEncoder {\n output.spikes.reserve(input.len());\n \n for (channel, &value) in input.iter().enumerate() {\n+ let Ok(channel) = u16::try_from(channel) else {\n+ // Remaining channels exceed u16::MAX; stop rather than wrap.\n+ break;\n+ };\n output.spikes.push(SpikeEvent {\n- channel: u16::try_from(channel).expect(\"channel index exceeds u16::MAX\"),\n+ channel,\n timestamp: self.timestamp_for(value),\n polarity: true,\n });\n@@ -94,6 +141,39 @@ impl Encoder for LatencyEncoder {\n }\n }\n \n+#[cfg(feature = \"serde\")]\n+impl<'de> serde::Deserialize<'de> for LatencyEncoder {\n+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n+ where\n+ D: serde::Deserializer<'de>,\n+ {\n+ #[derive(serde::Deserialize)]\n+ struct Helper {\n+ max_latency: u64,\n+ range: (f32, f32),\n+ }\n+\n+ let helper = Helper::deserialize(deserializer)?;\n+\n+ if !helper.range.0.is_finite()\n+ || !helper.range.1.is_finite()\n+ || !matches!(\n+ helper.range.0.partial_cmp(&helper.range.1),\n+ Some(std::cmp::Ordering::Less)\n+ )\n+ {\n+ return Err(serde::de::Error::custom(\n+ \"range must be finite and min must be less than max\",\n+ ));\n+ }\n+\n+ Ok(Self {\n+ max_latency: helper.max_latency,\n+ range: helper.range,\n+ })\n+ }\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\n@@ -198,8 +278,97 @@ mod tests {\n }\n \n #[test]\n- #[should_panic(expected = \"range min must be less than range max\")]\n+ fn latency_encoder_nan_maps_to_max_latency() {\n+ let mut encoder = LatencyEncoder::new(7, (0.0, 1.0));\n+\n+ let output = encoder.encode(&[f32::NAN, 1.0]);\n+\n+ assert_eq!(output.spikes[0].timestamp, 7);\n+ assert_eq!(output.spikes[1].timestamp, 0);\n+ }\n+\n+ #[test]\n+ #[should_panic(expected = \"range must be finite and min must be less than max\")]\n fn latency_encoder_rejects_invalid_range() {\n let _ = LatencyEncoder::new(5, (1.0, 1.0));\n }\n+\n+ #[test]\n+ #[should_panic(expected = \"range must be finite and min must be less than max\")]\n+ fn latency_encoder_rejects_infinite_range() {\n+ let _ = LatencyEncoder::new(10, (f32::NEG_INFINITY, f32::INFINITY));\n+ }\n+\n+ #[test]\n+ fn latency_encoder_truncates_channel_overflow() {\n+ let mut encoder = LatencyEncoder::new(1, (0.0, 1.0));\n+ let input = vec![0.0f32; (u16::MAX as usize) + 2];\n+ let output = encoder.encode(&input);\n+ assert_eq!(output.spikes.len(), u16::MAX as usize + 1);\n+ }\n+\n+ #[test]\n+ fn latency_encoder_encode_with_modulators_identity() {\n+ let mut encoder = LatencyEncoder::new(10, (0.0, 1.0));\n+ let curves = NeuromodulatorGainCurves::default();\n+ let mods = NeuroModulators::default();\n+\n+ let plain = encoder.encode(&[0.5]);\n+ let modulated = encoder.encode_with_modulators(&[0.5], &mods, &curves);\n+\n+ assert_eq!(plain.spikes[0].timestamp, modulated.spikes[0].timestamp);\n+ }\n+\n+ #[test]\n+ fn latency_encoder_encode_with_modulators_latency_scale() {\n+ let mut encoder = LatencyEncoder::new(10, (0.0, 1.0));\n+ let curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ latency: Some(GainCurve::new((0.0, 1.0), (0.5, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+ let mods = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+\n+ let output = encoder.encode_with_modulators(&[0.5], &mods, &curves);\n+ // latency_scale = 0.5, so max_latency = 10 * 0.5 = 5\n+ // normalized(0.5) = 0.5, timestamp = (1.0 - 0.5) * 5 = 2.5 → 3\n+ assert_eq!(output.spikes[0].timestamp, 3);\n+ }\n+\n+ #[test]\n+ fn latency_encoder_encode_step_with_modulators_matches_encode() {\n+ let mut encoder = LatencyEncoder::new(10, (0.0, 1.0));\n+ let curves = NeuromodulatorGainCurves::default();\n+ let mods = NeuroModulators::default();\n+\n+ let batch = encoder.encode_with_modulators(&[0.5], &mods, &curves);\n+ let step = encoder.encode_step_with_modulators(&[0.5], &mods, &curves);\n+\n+ assert_eq!(batch, step);\n+ }\n+\n+ #[test]\n+ fn latency_encoder_modulators_zero_scale_maps_to_zero() {\n+ let mut encoder = LatencyEncoder::new(10, (0.0, 1.0));\n+ let curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ latency: Some(GainCurve::new((0.0, 1.0), (1.0, 0.0))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+ let mods = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+\n+ let output = encoder.encode_with_modulators(&[0.5, f32::NAN], &mods, &curves);\n+ assert_eq!(output.spikes.len(), 2);\n+ assert!(output.spikes.iter().all(|s| s.timestamp == 0));\n+ }\n }\ndiff --git a/src/encoders/mod.rs b/src/encoders/mod.rs\nindex 19b6fff8..44b0889d 100644\n--- a/src/encoders/mod.rs\n+++ b/src/encoders/mod.rs\n@@ -1,14 +1,16 @@\n pub mod delta;\n pub mod derivative;\n pub mod latency;\n+pub mod phase;\n pub mod population;\n pub mod predictive;\n pub mod rate;\n pub mod temporal;\n \n-pub use delta::{encode_deltas_to_spikes, DeltaEncoder};\n+pub use delta::{DeltaEncoder, encode_deltas_to_spikes};\n pub use derivative::DerivativeEncoder;\n pub use latency::LatencyEncoder;\n+pub use phase::PhaseEncoder;\n pub use population::PopulationEncoder;\n pub use predictive::PredictiveEncoder;\n pub use rate::RateEncoder;\ndiff --git a/src/encoders/phase.rs b/src/encoders/phase.rs\nnew file mode 100644\nindex 00000000..57fcd61a\n--- /dev/null\n+++ b/src/encoders/phase.rs\n@@ -0,0 +1,426 @@\n+use crate::prelude::*;\n+\n+/// Encodes analog values as phase-locked spikes within a repeating oscillation cycle.\n+///\n+/// Each input channel produces at most one positive spike per call, with the spike\n+/// timestamp positioned relative to the current background phase according to the\n+/// normalized input value. Higher values map to later phase bins.\n+///\n+/// Timestamps are computed as `current_phase + phase_offset`, which keeps ordering\n+/// stable *within* a single encode call (higher-value channels get later timestamps).\n+/// Ordering *across* calls is not globally guaranteed, since `phase_offset` can exceed\n+/// the per-call phase advance. Cycle-relative phase is recoverable as\n+/// `timestamp % cycle_steps`.\n+#[derive(Clone, Debug, PartialEq)]\n+#[cfg_attr(feature = \"serde\", derive(serde::Serialize))]\n+pub struct PhaseEncoder {\n+ cycle_steps: u64,\n+ range: (f32, f32),\n+ current_phase: u64,\n+}\n+\n+/// Validates `cycle_steps` and `range`, returning an error message if invalid.\n+///\n+/// Shared by both `PhaseEncoder::new` (which panics on failure) and the\n+/// `Deserialize` impl (which surfaces the message as a deserialization error).\n+fn validate_params(cycle_steps: u64, range: (f32, f32)) -> Result<(), &'static str> {\n+ if cycle_steps == 0 {\n+ return Err(\"cycle_steps must be greater than 0\");\n+ }\n+ if !range.0.is_finite() || !range.1.is_finite() || range.0 >= range.1 {\n+ return Err(\"range min must be less than range max and both must be finite\");\n+ }\n+ Ok(())\n+}\n+\n+impl PhaseEncoder {\n+ /// Creates a new `PhaseEncoder`.\n+ ///\n+ /// # Panics\n+ ///\n+ /// Panics if `cycle_steps == 0` or if range bounds are non-finite or `range.0 >= range.1`.\n+ pub fn new(cycle_steps: u64, range: (f32, f32)) -> Self {\n+ if let Err(message) = validate_params(cycle_steps, range) {\n+ panic!(\"{message}\");\n+ }\n+\n+ Self {\n+ cycle_steps,\n+ range,\n+ current_phase: 0,\n+ }\n+ }\n+\n+ fn normalize(&self, value: f32) -> f64 {\n+ // Use f64 to prevent overflow for valid f32 ranges (e.g., f32::MIN..f32::MAX).\n+ let clamped = value.clamp(self.range.0, self.range.1) as f64;\n+ let lo = self.range.0 as f64;\n+ let hi = self.range.1 as f64;\n+ (clamped - lo) / (hi - lo)\n+ }\n+\n+ fn phase_offset(&self, normalized: f64) -> u64 {\n+ ((normalized * self.cycle_steps as f64).floor() as u64).min(self.cycle_steps - 1)\n+ }\n+\n+ fn encode_current_cycle(&self, input: &[f32]) -> EncodedOutput {\n+ let mut output = EncodedOutput::new();\n+\n+ for (channel, &value) in input.iter().enumerate() {\n+ // Non-finite inputs are invalid readings — skip rather than emit a\n+ // misleading phase-0 spike (NaN as u64 saturates to 0).\n+ if !value.is_finite() {\n+ continue;\n+ }\n+\n+ let Ok(channel_u16) = u16::try_from(channel) else {\n+ // Remaining channels exceed u16::MAX; stop rather than wrap.\n+ break;\n+ };\n+\n+ let phase_offset = self.phase_offset(self.normalize(value));\n+ // Monotonic timestamps preserve higher-value → later-phase ordering\n+ // even when phase_offset would wrap a modular cycle counter.\n+ output.spikes.push(SpikeEvent {\n+ channel: channel_u16,\n+ timestamp: self.current_phase.saturating_add(phase_offset),\n+ polarity: true,\n+ });\n+ }\n+\n+ output\n+ }\n+\n+ fn advance_phase(&mut self) {\n+ self.current_phase = self.current_phase.saturating_add(1);\n+ }\n+\n+ fn encode_current_cycle_with_sensitivity_scale(\n+ &self,\n+ input: &[f32],\n+ sensitivity_scale: f32,\n+ ) -> EncodedOutput {\n+ let mut output = EncodedOutput::new();\n+\n+ // Guard: zero or non-finite sensitivity collapses the range, suppressing all output.\n+ if !sensitivity_scale.is_finite() || sensitivity_scale <= 0.0 {\n+ return output;\n+ }\n+\n+ // Use f64 to prevent overflow for valid f32 ranges and scales.\n+ let lo = self.range.0 as f64;\n+ let hi = lo + (self.range.1 as f64 - lo) * (sensitivity_scale as f64);\n+\n+ for (channel, &value) in input.iter().enumerate() {\n+ if !value.is_finite() {\n+ continue;\n+ }\n+\n+ let Ok(channel_u16) = u16::try_from(channel) else {\n+ break;\n+ };\n+\n+ let normalized = ((value as f64 - lo) / (hi - lo)).clamp(0.0, 1.0);\n+ let phase_offset = self.phase_offset(normalized);\n+ output.spikes.push(SpikeEvent {\n+ channel: channel_u16,\n+ timestamp: self.current_phase.saturating_add(phase_offset),\n+ polarity: true,\n+ });\n+ }\n+\n+ output\n+ }\n+\n+ /// Encode input using neuromodulator-driven gain curves.\n+ ///\n+ /// Evaluates `gain_curves` against the current `modulators` to produce\n+ /// an [`EncodingGains`], then uses the `sensitivity_scale` component to\n+ /// modulate the input-to-phase mapping range. Values > 1.0 widen the range\n+ /// (less sensitive); values in (0, 1) narrow it (more sensitive).\n+ pub fn encode_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ let output =\n+ self.encode_current_cycle_with_sensitivity_scale(input, gains.sensitivity_scale);\n+ self.advance_phase();\n+ output\n+ }\n+\n+ /// Step-wise variant of [`encode_with_modulators`](Self::encode_with_modulators).\n+ ///\n+ /// Identical behavior — provided for API symmetry with the [`Encoder`] trait's\n+ /// `encode` / `encode_step` pair.\n+ pub fn encode_step_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ let output =\n+ self.encode_current_cycle_with_sensitivity_scale(input, gains.sensitivity_scale);\n+ self.advance_phase();\n+ output\n+ }\n+}\n+\n+impl Encoder for PhaseEncoder {\n+ fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ let output = self.encode_current_cycle(input);\n+ self.advance_phase();\n+ output\n+ }\n+\n+ fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {\n+ // Streaming and batch modes share the same phase-step semantics for\n+ // this encoder: each call advances the background oscillation by one.\n+ let output = self.encode_current_cycle(input);\n+ self.advance_phase();\n+ output\n+ }\n+\n+ fn reset(&mut self) {\n+ self.current_phase = 0;\n+ }\n+}\n+\n+#[cfg(feature = \"serde\")]\n+impl<'de> serde::Deserialize<'de> for PhaseEncoder {\n+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n+ where\n+ D: serde::Deserializer<'de>,\n+ {\n+ #[derive(serde::Deserialize)]\n+ struct Helper {\n+ cycle_steps: u64,\n+ range: (f32, f32),\n+ #[serde(default)]\n+ current_phase: u64,\n+ }\n+\n+ let helper = Helper::deserialize(deserializer)?;\n+\n+ validate_params(helper.cycle_steps, helper.range).map_err(serde::de::Error::custom)?;\n+\n+ Ok(Self {\n+ cycle_steps: helper.cycle_steps,\n+ range: helper.range,\n+ current_phase: helper.current_phase,\n+ })\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn test_wide_range_normalizes_without_nan() {\n+ let mut encoder = PhaseEncoder::new(8, (f32::MIN, f32::MAX));\n+ let output = encoder.encode(&[f32::MAX]);\n+ assert_eq!(output.spikes.len(), 1);\n+ // f32::MAX maps to the last phase bin, not NaN → phase 0.\n+ assert_eq!(output.spikes[0].timestamp, 7);\n+ }\n+\n+ #[test]\n+ fn test_phase_mapping_clamps_and_quantizes() {\n+ let mut encoder = PhaseEncoder::new(8, (0.0, 10.0));\n+\n+ let output = encoder.encode(&[-5.0, 0.0, 5.0, 10.0, 15.0]);\n+ let timestamps: Vec<u64> = output.spikes.iter().map(|spike| spike.timestamp).collect();\n+ let polarities: Vec<bool> = output.spikes.iter().map(|spike| spike.polarity).collect();\n+\n+ assert_eq!(timestamps, vec![0, 0, 4, 7, 7]);\n+ assert_eq!(polarities, vec![true; 5]);\n+ }\n+\n+ #[test]\n+ fn test_phase_advances_after_each_call() {\n+ let mut encoder = PhaseEncoder::new(4, (0.0, 1.0));\n+\n+ assert_eq!(encoder.encode(&[0.0]).spikes[0].timestamp, 0);\n+ assert_eq!(encoder.encode(&[0.0]).spikes[0].timestamp, 1);\n+ assert_eq!(encoder.encode_step(&[0.0]).spikes[0].timestamp, 2);\n+ assert_eq!(encoder.encode_step(&[0.0]).spikes[0].timestamp, 3);\n+ // Monotonic absolute phase time (cycle phase is timestamp % cycle_steps).\n+ assert_eq!(encoder.encode(&[0.0]).spikes[0].timestamp, 4);\n+ assert_eq!(encoder.encode(&[0.0]).spikes[0].timestamp % 4, 1);\n+ }\n+\n+ #[test]\n+ fn test_within_call_ordering_preserved_after_phase_advance() {\n+ let mut encoder = PhaseEncoder::new(8, (0.0, 1.0));\n+ // Advance near the end of a modular cycle so a wrap would reorder.\n+ for _ in 0..6 {\n+ encoder.encode(&[0.0]);\n+ }\n+ let output = encoder.encode(&[0.125, 0.375]); // offsets 1 and 3\n+ let timestamps: Vec<u64> = output.spikes.iter().map(|s| s.timestamp).collect();\n+ // 6+1=7, 6+3=9 — strictly ordered (no modular wrap inversion).\n+ assert_eq!(timestamps, vec![7, 9]);\n+ assert!(timestamps[0] < timestamps[1]);\n+ }\n+\n+ #[test]\n+ fn test_reset_restores_initial_phase() {\n+ let mut encoder = PhaseEncoder::new(8, (0.0, 1.0));\n+\n+ encoder.encode(&[0.0]);\n+ encoder.encode(&[0.0]);\n+ encoder.reset();\n+\n+ let output = encoder.encode(&[1.0]);\n+ assert_eq!(output.spikes[0].timestamp, 7);\n+ }\n+\n+ #[test]\n+ fn test_empty_input_returns_no_spikes() {\n+ let mut encoder = PhaseEncoder::new(4, (0.0, 1.0));\n+\n+ let output = encoder.encode(&[]);\n+ assert!(output.spikes.is_empty());\n+\n+ let next_output = encoder.encode(&[0.0]);\n+ assert_eq!(next_output.spikes[0].timestamp, 1);\n+ }\n+\n+ #[test]\n+ fn test_nan_input_skips_channel() {\n+ let mut encoder = PhaseEncoder::new(8, (0.0, 1.0));\n+ let output = encoder.encode(&[0.0, f32::NAN, 1.0]);\n+ assert_eq!(output.spikes.len(), 2);\n+ assert_eq!(output.spikes[0].channel, 0);\n+ assert_eq!(output.spikes[1].channel, 2);\n+ }\n+\n+ #[test]\n+ #[should_panic(expected = \"cycle_steps must be greater than 0\")]\n+ fn test_zero_cycle_steps_rejected() {\n+ let _ = PhaseEncoder::new(0, (0.0, 1.0));\n+ }\n+\n+ #[test]\n+ #[should_panic(expected = \"range min must be less than range max\")]\n+ fn test_invalid_range_rejected() {\n+ let _ = PhaseEncoder::new(8, (1.0, 1.0));\n+ }\n+\n+ #[test]\n+ fn test_encode_step_matches_encode() {\n+ let input = [2.5, 7.5];\n+ let mut encode_encoder = PhaseEncoder::new(8, (0.0, 10.0));\n+ let mut step_encoder = PhaseEncoder::new(8, (0.0, 10.0));\n+\n+ assert_eq!(\n+ encode_encoder.encode(&input),\n+ step_encoder.encode_step(&input)\n+ );\n+ assert_eq!(\n+ encode_encoder.encode(&input),\n+ step_encoder.encode_step(&input)\n+ );\n+ }\n+\n+ #[cfg(feature = \"serde\")]\n+ #[test]\n+ fn test_deserialize_rejects_zero_cycle_steps() {\n+ let json = r#\"{\"cycle_steps\":0,\"range\":[0.0,1.0],\"current_phase\":0}\"#;\n+ let err = serde_json::from_str::<PhaseEncoder>(json).unwrap_err();\n+ assert!(err.to_string().contains(\"cycle_steps\"));\n+ }\n+\n+ #[test]\n+ fn test_encode_with_modulators_identity() {\n+ let mut encoder = PhaseEncoder::new(8, (0.0, 1.0));\n+ let curves = NeuromodulatorGainCurves::default();\n+ let mods = NeuroModulators::default();\n+\n+ let plain = encoder.encode(&[0.5]);\n+ let mut encoder2 = PhaseEncoder::new(8, (0.0, 1.0));\n+ let modulated = encoder2.encode_with_modulators(&[0.5], &mods, &curves);\n+\n+ assert_eq!(plain.spikes[0].timestamp, modulated.spikes[0].timestamp);\n+ }\n+\n+ #[test]\n+ fn test_encode_with_modulators_sensitivity_scale() {\n+ let mut encoder = PhaseEncoder::new(8, (0.0, 1.0));\n+ let curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ sensitivity: Some(GainCurve::new((0.0, 1.0), (0.5, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+ let mods = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+\n+ let output = encoder.encode_with_modulators(&[0.5], &mods, &curves);\n+ // sensitivity_scale = 0.5, range = (0.0, 0.5)\n+ // value 0.5 maps to normalized 1.0, phase_offset = 7\n+ assert_eq!(output.spikes[0].timestamp, 7);\n+ }\n+\n+ #[test]\n+ fn test_encode_step_with_modulators_matches_encode() {\n+ let input = [0.5];\n+ let curves = NeuromodulatorGainCurves::default();\n+ let mods = NeuroModulators::default();\n+\n+ let mut encoder1 = PhaseEncoder::new(8, (0.0, 1.0));\n+ let mut encoder2 = PhaseEncoder::new(8, (0.0, 1.0));\n+\n+ let batch = encoder1.encode_with_modulators(&input, &mods, &curves);\n+ let step = encoder2.encode_step_with_modulators(&input, &mods, &curves);\n+\n+ assert_eq!(batch, step);\n+ }\n+\n+ #[test]\n+ fn test_encode_with_modulators_zero_sensitivity_suppresses() {\n+ let mut encoder = PhaseEncoder::new(8, (0.0, 1.0));\n+ let curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ sensitivity: Some(GainCurve::new((0.0, 1.0), (0.0, 0.0))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+ let mods = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+\n+ let output = encoder.encode_with_modulators(&[0.5], &mods, &curves);\n+ assert!(output.spikes.is_empty());\n+ }\n+\n+ #[test]\n+ fn test_encode_with_modulators_nan_input_skips() {\n+ let mut encoder = PhaseEncoder::new(8, (0.0, 1.0));\n+ let curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ sensitivity: Some(GainCurve::new((0.0, 1.0), (1.0, 1.0))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+ let mods = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+\n+ let output = encoder.encode_with_modulators(&[0.0, f32::NAN, 1.0], &mods, &curves);\n+ assert_eq!(output.spikes.len(), 2);\n+ assert_eq!(output.spikes[0].channel, 0);\n+ assert_eq!(output.spikes[1].channel, 2);\n+ }\n+}\ndiff --git a/src/encoders/population.rs b/src/encoders/population.rs\nindex 9d43a86b..30ff76aa 100644\n--- a/src/encoders/population.rs\n+++ b/src/encoders/population.rs\n@@ -51,29 +51,64 @@ impl PopulationEncoder {\n self.num_neurons\n }\n \n- /// Calculates the firing rate for a neuron based on a Gaussian tuning curve.\n- fn get_rate(&self, input: f32, neuron_index: usize) -> f32 {\n+ fn get_rate_with_tuning_width(\n+ &self,\n+ input: f32,\n+ neuron_index: usize,\n+ tuning_width: f32,\n+ ) -> f32 {\n let range_span = self.input_range.1 - self.input_range.0;\n let preferred_value =\n self.input_range.0 + (neuron_index as f32 / self.num_neurons as f32) * range_span;\n \n let distance = (input - preferred_value).abs();\n // Gaussian-like response curve\n- (-(distance * distance) / (2.0 * self.tuning_width * self.tuning_width)).exp()\n+ (-(distance * distance) / (2.0 * tuning_width * tuning_width)).exp()\n }\n-}\n \n-impl Encoder for PopulationEncoder {\n- fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ /// Effective tuning width under a sensitivity gain.\n+ ///\n+ /// Scales **≥ 1** narrow the Gaussian (`width / scale`) so high sensitivity is\n+ /// more selective. Scales in **(0, 1)** keep the base width and rely on rate\n+ /// scaling in `encode_with_sensitivity_scale` so low (but nonzero) gain\n+ /// *suppresses* activity instead of widening toward universal firing.\n+ fn effective_tuning_width(&self, sensitivity_scale: f32) -> f32 {\n+ if !sensitivity_scale.is_finite() || sensitivity_scale <= 0.0 {\n+ return self.tuning_width.max(f32::EPSILON);\n+ }\n+ if sensitivity_scale >= 1.0 {\n+ return (self.tuning_width / sensitivity_scale).max(f32::EPSILON);\n+ }\n+ // Sub-unity: do not widen; rate scaling handles suppression.\n+ self.tuning_width.max(f32::EPSILON)\n+ }\n+\n+ fn encode_with_sensitivity_scale(\n+ &mut self,\n+ input: &[f32],\n+ sensitivity_scale: f32,\n+ ) -> EncodedOutput {\n let mut output = EncodedOutput::new();\n+ // Zero/negative/non-finite sensitivity fully suppresses population responses.\n+ if !sensitivity_scale.is_finite() || sensitivity_scale <= 0.0 {\n+ return output;\n+ }\n+ let tuning_width = self.effective_tuning_width(sensitivity_scale);\n+ // Rate gain: scales > 1 also narrow width; scales in (0, 1) only reduce rate\n+ // so small positive gains never produce near-universal firing.\n+ let rate_gain = sensitivity_scale.min(1.0);\n \n // This encoder expects a single value in the input slice\n if let Some(&value) = input.first() {\n for i in 0..self.num_neurons {\n- let rate = self.get_rate(value, i);\n+ let Ok(channel) = u16::try_from(i) else {\n+ // Remaining neurons exceed u16::MAX; stop rather than wrap.\n+ break;\n+ };\n+ let rate = self.get_rate_with_tuning_width(value, i, tuning_width) * rate_gain;\n if crate::rng::gen_unit_f32() < rate {\n output.spikes.push(SpikeEvent {\n- channel: u16::try_from(i).expect(\"channel index exceeds u16::MAX\"),\n+ channel,\n timestamp: 0, // Simplified\n polarity: true,\n });\n@@ -83,6 +118,44 @@ impl Encoder for PopulationEncoder {\n output\n }\n \n+ /// Encode input using neuromodulator-driven gain curves.\n+ ///\n+ /// Evaluates `gain_curves` against the current `modulators` to produce\n+ /// an [`EncodingGains`], then uses the `sensitivity_scale` component to\n+ /// modulate the population tuning width. Values >= 1.0 narrow the\n+ /// Gaussian (more selective); values in (0, 1) suppress firing rate\n+ /// without widening.\n+ ///\n+ /// Expected modulator range: any finite f32. Expected gain range after\n+ /// sanitization: `[0.0, 10,000.0]`.\n+ pub fn encode_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_sensitivity_scale(input, gains.sensitivity_scale)\n+ }\n+\n+ /// Step-wise variant of [`encode_with_modulators`](Self::encode_with_modulators).\n+ /// Identical behavior, provided for API symmetry with the [`Encoder`] trait.\n+ pub fn encode_step_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_sensitivity_scale(input, gains.sensitivity_scale)\n+ }\n+}\n+\n+impl Encoder for PopulationEncoder {\n+ fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ self.encode_with_sensitivity_scale(input, 1.0)\n+ }\n+\n fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {\n self.encode(input)\n }\n@@ -105,7 +178,9 @@ mod tests {\n \n // The neuron whose preferred value is closest to 50.0 should have the highest chance of firing.\n // We can't guarantee a spike due to the probabilistic nature, but we can check the rates.\n- let rates: Vec<f32> = (0..10).map(|i| encoder.get_rate(50.0, i)).collect();\n+ let rates: Vec<f32> = (0..10)\n+ .map(|i| encoder.get_rate_with_tuning_width(50.0, i, encoder.tuning_width))\n+ .collect();\n let max_rate_index = rates\n .iter()\n .enumerate()\n@@ -122,15 +197,126 @@ mod tests {\n }\n \n #[test]\n- fn test_population_encoder_empty_input() {\n- let mut encoder = PopulationEncoder::new(10, (0.0, 1.0), 0.1);\n- let output = encoder.encode(&[]);\n+ fn test_effective_tuning_width_sub_unity() {\n+ let encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ // Sub-unity sensitivity should NOT widen the tuning width\n+ let width = encoder.effective_tuning_width(0.5);\n+ assert_eq!(width, encoder.tuning_width.max(f32::EPSILON));\n+ }\n+\n+ #[test]\n+ fn test_effective_tuning_width_zero_and_negative() {\n+ let encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ assert_eq!(\n+ encoder.effective_tuning_width(0.0),\n+ encoder.tuning_width.max(f32::EPSILON)\n+ );\n+ assert_eq!(\n+ encoder.effective_tuning_width(-1.0),\n+ encoder.tuning_width.max(f32::EPSILON)\n+ );\n+ assert_eq!(\n+ encoder.effective_tuning_width(f32::NAN),\n+ encoder.tuning_width.max(f32::EPSILON)\n+ );\n+ }\n+\n+ #[test]\n+ fn test_encode_with_zero_sensitivity_returns_empty() {\n+ let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ let output = encoder.encode_with_sensitivity_scale(&[50.0], 0.0);\n assert!(output.spikes.is_empty());\n }\n \n #[test]\n- fn test_population_encoder_reset() {\n- let mut encoder = PopulationEncoder::new(10, (0.0, 1.0), 0.1);\n+ fn test_encode_with_negative_sensitivity_returns_empty() {\n+ let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ let output = encoder.encode_with_sensitivity_scale(&[50.0], -1.0);\n+ assert!(output.spikes.is_empty());\n+ }\n+\n+ #[test]\n+ fn test_encode_with_nan_sensitivity_returns_empty() {\n+ let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ let output = encoder.encode_with_sensitivity_scale(&[50.0], f32::NAN);\n+ assert!(output.spikes.is_empty());\n+ }\n+\n+ #[test]\n+ fn test_sub_unity_sensitivity_suppresses_firing() {\n+ let encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ // Sub-unity scale should NOT widen tuning width (that's handled by effective_tuning_width)\n+ // but the rate_gain = scale.min(1.0) should suppress firing probability.\n+ let baseline_width = encoder.effective_tuning_width(1.0);\n+ let suppressed_width = encoder.effective_tuning_width(0.1);\n+ // Widths should be equal (sub-unity doesn't widen)\n+ assert_eq!(baseline_width, suppressed_width);\n+\n+ // Rate gain at 0.1 should be 0.1x the baseline rate\n+ let baseline_rate = encoder.get_rate_with_tuning_width(50.0, 5, baseline_width);\n+ let suppressed_rate = encoder.get_rate_with_tuning_width(50.0, 5, suppressed_width) * 0.1;\n+ // Suppressed rate should be substantially lower\n+ assert!(\n+ suppressed_rate < baseline_rate * 0.15,\n+ \"suppressed_rate {} should be < 15% of baseline_rate {}\",\n+ suppressed_rate,\n+ baseline_rate\n+ );\n+ }\n+\n+ #[test]\n+ fn test_encode_with_modulators_uses_gain_curves() {\n+ let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ let mods = NeuroModulators::default();\n+ let curves = NeuromodulatorGainCurves::default();\n+ // With identity gains, should produce similar output to plain encode\n+ let output = encoder.encode_with_modulators(&[50.0], &mods, &curves);\n+ assert!(output.spikes.len() <= 10);\n+ }\n+\n+ #[test]\n+ fn test_encode_step_with_modulators() {\n+ let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ let mods = NeuroModulators::default();\n+ let curves = NeuromodulatorGainCurves::default();\n+ let output = encoder.encode_step_with_modulators(&[50.0], &mods, &curves);\n+ assert!(output.spikes.len() <= 10);\n+ }\n+\n+ #[test]\n+ fn test_population_encoder_modulators_adjust_sensitivity() {\n+ let encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ let modulators = NeuroModulators {\n+ tempo: 1.0,\n+ ..Default::default()\n+ };\n+ let gain_curves = NeuromodulatorGainCurves {\n+ tempo: ModulatorGainCurves {\n+ sensitivity: Some(GainCurve::new((0.0, 1.0), (1.0, 2.0))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+\n+ let baseline_width = encoder.effective_tuning_width(1.0);\n+ let modulated_width =\n+ encoder.effective_tuning_width(gain_curves.evaluate(&modulators).sensitivity_scale);\n+ let baseline_rate = encoder.get_rate_with_tuning_width(50.0, 0, baseline_width);\n+ let modulated_rate = encoder.get_rate_with_tuning_width(50.0, 0, modulated_width);\n+\n+ assert!(modulated_width < baseline_width);\n+ assert!(modulated_rate < baseline_rate);\n+ }\n+\n+ #[test]\n+ fn test_population_encoder_step_and_accessors() {\n+ let mut encoder = PopulationEncoder::new(10, (0.0, 100.0), 10.0);\n+ assert_eq!(encoder.num_neurons(), 10);\n+\n+ let step_output = encoder.encode_step(&[50.0]);\n+ assert!(step_output.spikes.len() <= 10);\n+\n encoder.reset();\n+ assert_eq!(encoder.num_neurons(), 10);\n }\n }\ndiff --git a/src/encoders/predictive.rs b/src/encoders/predictive.rs\nindex 90af7f56..c033599d 100644\n--- a/src/encoders/predictive.rs\n+++ b/src/encoders/predictive.rs\n@@ -58,10 +58,12 @@ impl PredictiveEncoder {\n deviation_thresholds,\n }\n }\n-}\n \n-impl Encoder for PredictiveEncoder {\n- fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ fn encode_with_threshold_scale(\n+ &mut self,\n+ input: &[f32],\n+ threshold_scale: f32,\n+ ) -> EncodedOutput {\n let mut output = EncodedOutput::new();\n for (i, &value) in input.iter().enumerate() {\n if i >= self.history.len() {\n@@ -83,9 +85,12 @@ impl Encoder for PredictiveEncoder {\n let deviation = (value - self.thresholds[i]).abs();\n \n for &(threshold, _spike_val) in self.deviation_thresholds.iter().rev() {\n- if deviation > threshold {\n+ if deviation > (threshold * threshold_scale).max(0.0) {\n+ let Ok(channel) = u16::try_from(i) else {\n+ break;\n+ };\n output.spikes.push(SpikeEvent {\n- channel: u16::try_from(i).expect(\"channel index exceeds u16::MAX\"),\n+ channel,\n timestamp: 0, // Simplified\n polarity: true, // Indicates a deviation spike\n });\n@@ -96,13 +101,63 @@ impl Encoder for PredictiveEncoder {\n output\n }\n \n+ /// Encode input using neuromodulator-driven gain curves.\n+ ///\n+ /// Evaluates `gain_curves` against the current `modulators` to produce\n+ /// an [`EncodingGains`], then uses the `threshold_scale` component to\n+ /// modulate the deviation detection threshold. Values > 1.0 raise the\n+ /// effective threshold (less sensitive — larger deviations required to\n+ /// spike); values in (0, 1) lower it (more sensitive).\n+ ///\n+ /// Input is truncated to the number of tracked channels. Expected\n+ /// modulator range: any finite f32. Expected gain range after\n+ /// sanitization: `[0.0, 10,000.0]`.\n+ pub fn encode_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ // Match encode_step_with_modulators: only process channels we track.\n+ let safe_input = if input.len() > self.history.len() {\n+ &input[..self.history.len()]\n+ } else {\n+ input\n+ };\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_threshold_scale(safe_input, gains.threshold_scale)\n+ }\n+\n+ /// Step-wise variant of [`encode_with_modulators`](Self::encode_with_modulators).\n+ /// Identical behavior, provided for API symmetry with the [`Encoder`] trait.\n+ pub fn encode_step_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let safe_input = if input.len() > self.history.len() {\n+ &input[..self.history.len()]\n+ } else {\n+ input\n+ };\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_threshold_scale(safe_input, gains.threshold_scale)\n+ }\n+}\n+\n+impl Encoder for PredictiveEncoder {\n+ fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ self.encode_with_threshold_scale(input, 1.0)\n+ }\n+\n fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {\n let safe_input = if input.len() > self.history.len() {\n &input[..self.history.len()]\n } else {\n input\n };\n- self.encode(safe_input)\n+ self.encode_with_threshold_scale(safe_input, 1.0)\n }\n \n fn reset(&mut self) {\n@@ -182,37 +237,140 @@ mod tests {\n }\n \n #[test]\n- fn test_predictive_encoder_history_limit() {\n- let mut encoder = PredictiveEncoder::new(5, vec![(1.0, 1)], 1);\n- for _ in 0..10 {\n- encoder.encode(&[1.0]);\n+ fn test_predictive_encoder_reset() {\n+ let mut encoder = PredictiveEncoder::new(5, vec![(2.0, 1)], 2);\n+ for _ in 0..6 {\n+ encoder.encode(&[1.0, 2.0]);\n }\n- assert_eq!(encoder.history[0].len(), 5);\n+ encoder.reset();\n+ assert!(encoder.history.iter().all(|h| h.is_empty()));\n+ assert!(encoder.thresholds.iter().all(|&t| t == 0.0));\n }\n \n #[test]\n- fn test_predictive_encoder_reset() {\n- let mut encoder = PredictiveEncoder::new(5, vec![(1.0, 1)], 1);\n- encoder.encode(&[1.0; 5]);\n+ fn test_predictive_encoder_multi_channel() {\n+ let mut encoder = PredictiveEncoder::new(5, vec![(2.0, 1)], 3);\n+ for _ in 0..6 {\n+ encoder.encode(&[1.0, 2.0, 3.0]);\n+ }\n+ let output = encoder.encode(&[10.0, 20.0, 30.0]);\n+ // All channels should spike on large deviation\n+ assert!(!output.spikes.is_empty());\n+ }\n+\n+ #[test]\n+ fn test_predictive_encoder_input_truncation() {\n+ let mut encoder = PredictiveEncoder::new(5, vec![(2.0, 1)], 2);\n+ for _ in 0..6 {\n+ // 4 values but only 2 channels tracked — should truncate\n+ encoder.encode(&[1.0, 2.0, 3.0, 4.0]);\n+ }\n+ // Should not panic; only first 2 channels processed\n+ let output = encoder.encode(&[10.0, 20.0, 30.0, 40.0]);\n+ assert!(output.spikes.len() <= 2);\n+ }\n+\n+ #[test]\n+ fn test_predictive_encoder_step_input_truncation() {\n+ let mut encoder = PredictiveEncoder::new(5, vec![(2.0, 1)], 2);\n+ for _ in 0..6 {\n+ encoder.encode_step(&[1.0, 2.0, 3.0]);\n+ }\n+ let output = encoder.encode_step(&[10.0, 20.0, 30.0]);\n+ assert!(output.spikes.len() <= 2);\n+ }\n+\n+ #[test]\n+ fn test_predictive_encoder_encode_with_modulators() {\n+ let mut encoder = PredictiveEncoder::new(5, vec![(5.0, 1)], 1);\n+ let mods = NeuroModulators {\n+ acetylcholine: 1.0,\n+ ..Default::default()\n+ };\n+ let curves = NeuromodulatorGainCurves {\n+ acetylcholine: ModulatorGainCurves {\n+ threshold: Some(GainCurve::new((0.0, 1.0), (1.0, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+ for _ in 0..5 {\n+ encoder.encode_with_modulators(&[1.0], &mods, &curves);\n+ }\n+ let output = encoder.encode_with_modulators(&[5.0], &mods, &curves);\n+ assert_eq!(output.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_predictive_encoder_modulators_reduce_threshold() {\n+ let mut encoder = PredictiveEncoder::new(5, vec![(5.0, 1)], 1);\n+ let modulators = NeuroModulators {\n+ acetylcholine: 1.0,\n+ ..Default::default()\n+ };\n+ let gain_curves = NeuromodulatorGainCurves {\n+ acetylcholine: ModulatorGainCurves {\n+ threshold: Some(GainCurve::new((0.0, 1.0), (1.0, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+\n+ for _ in 0..5 {\n+ encoder.encode(&[1.0]);\n+ }\n+ assert!(encoder.encode(&[5.0]).spikes.is_empty());\n+\n encoder.reset();\n- assert_eq!(encoder.history[0].len(), 0);\n- assert_eq!(encoder.thresholds[0], 0.0);\n+\n+ for _ in 0..5 {\n+ encoder.encode_step_with_modulators(&[1.0], &modulators, &gain_curves);\n+ }\n+ let output = encoder.encode_step_with_modulators(&[5.0], &modulators, &gain_curves);\n+ assert_eq!(output.spikes.len(), 1);\n }\n \n #[test]\n- fn test_predictive_encoder_mismatched_input() {\n- let mut encoder = PredictiveEncoder::new(5, vec![(1.0, 1)], 2);\n- let _output = encoder.encode(&[1.0]);\n- assert_eq!(encoder.history[0].len(), 1);\n- assert_eq!(encoder.history[1].len(), 0);\n+ fn test_predictive_encoder_encode_with_modulators_truncate() {\n+ let mut encoder = PredictiveEncoder::new(5, vec![(5.0, 1)], 1);\n+ let mods = NeuroModulators {\n+ acetylcholine: 1.0,\n+ ..Default::default()\n+ };\n+ let curves = NeuromodulatorGainCurves {\n+ acetylcholine: ModulatorGainCurves {\n+ threshold: Some(GainCurve::new((0.0, 1.0), (1.0, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+ for _ in 0..5 {\n+ encoder.encode_with_modulators(&[1.0, 2.0], &mods, &curves);\n+ }\n+ let output = encoder.encode_with_modulators(&[5.0, 6.0], &mods, &curves);\n+ assert_eq!(output.spikes.len(), 1);\n+ }\n \n- let output2 = encoder.encode_step(&[1.0, 2.0, 3.0]);\n- assert_eq!(output2.spikes.len(), 0); // Not enough history\n+ #[test]\n+ fn test_predictive_encoder_step_shorter_input() {\n+ let mut encoder = PredictiveEncoder::new(5, vec![(2.0, 1)], 2);\n+ for _ in 0..6 {\n+ encoder.encode_step(&[1.0]);\n+ }\n+ let output = encoder.encode_step(&[10.0]);\n+ assert!(!output.spikes.is_empty());\n }\n \n+ #[cfg(feature = \"serde\")]\n #[test]\n- #[should_panic(expected = \"history_depth must be at least 5\")]\n- fn test_predictive_encoder_invalid_depth() {\n- let _ = PredictiveEncoder::new(4, vec![], 1);\n+ fn test_predictive_serde_history_channel_too_long() {\n+ let json = r#\"{\n+ \"history\": [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0]],\n+ \"thresholds\": [0.0],\n+ \"history_depth\": 5,\n+ \"deviation_thresholds\": []\n+ }\"#;\n+ let res: Result<PredictiveEncoder, _> = serde_json::from_str(json);\n+ assert!(res.is_err());\n }\n }\ndiff --git a/src/encoders/rate.rs b/src/encoders/rate.rs\nindex a79bf985..615b49fb 100644\n--- a/src/encoders/rate.rs\n+++ b/src/encoders/rate.rs\n@@ -61,23 +61,26 @@ impl RateEncoder {\n self.accumulators.resize(num_channels, 0.0);\n }\n }\n-}\n \n-impl Encoder for RateEncoder {\n- fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ fn encode_with_rate_scale(&mut self, input: &[f32], rate_scale: f32) -> EncodedOutput {\n let mut output = EncodedOutput::new();\n if input.is_empty() {\n return output;\n }\n \n for (i, &value) in input.iter().enumerate() {\n+ let Ok(channel) = u16::try_from(i) else {\n+ // Remaining channels exceed u16::MAX; stop rather than wrap.\n+ break;\n+ };\n let normalized = self.normalize(value);\n- let rate = self.base_rate + normalized * (self.max_rate - self.base_rate);\n+ let rate =\n+ (self.base_rate + normalized * (self.max_rate - self.base_rate)) * rate_scale;\n let probability = (rate / 10.0).clamp(0.0, 1.0);\n \n if crate::rng::gen_unit_f32() < probability {\n output.spikes.push(SpikeEvent {\n- channel: u16::try_from(i).expect(\"channel index exceeds u16::MAX\"),\n+ channel,\n timestamp: 0,\n polarity: true,\n });\n@@ -87,7 +90,7 @@ impl Encoder for RateEncoder {\n output\n }\n \n- fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {\n+ fn encode_step_with_rate_scale(&mut self, input: &[f32], rate_scale: f32) -> EncodedOutput {\n let mut output = EncodedOutput::new();\n if input.is_empty() {\n return output;\n@@ -96,14 +99,19 @@ impl Encoder for RateEncoder {\n self.ensure_accumulators(input.len());\n \n for (i, &value) in input.iter().enumerate() {\n+ let Ok(channel) = u16::try_from(i) else {\n+ // Remaining channels exceed u16::MAX; stop rather than wrap.\n+ break;\n+ };\n let normalized = self.normalize(value);\n- let rate_increment =\n- (self.base_rate + normalized * (self.max_rate - self.base_rate)) / 10.0;\n- self.accumulators[i] += rate_increment;\n+ let rate_increment = ((self.base_rate + normalized * (self.max_rate - self.base_rate))\n+ * rate_scale)\n+ / 10.0;\n+ self.accumulators[i] += rate_increment.max(0.0);\n \n while self.accumulators[i] >= 1.0 {\n output.spikes.push(SpikeEvent {\n- channel: u16::try_from(i).expect(\"channel index exceeds u16::MAX\"),\n+ channel,\n timestamp: 0,\n polarity: true,\n });\n@@ -114,6 +122,48 @@ impl Encoder for RateEncoder {\n output\n }\n \n+ /// Encode input using neuromodulator-driven gain curves.\n+ ///\n+ /// Evaluates `gain_curves` against the current `modulators` to produce\n+ /// an [`EncodingGains`], then uses the `firing_rate_scale` component to\n+ /// modulate the base firing rate. Values > 1.0 increase spike frequency;\n+ /// values < 1.0 decrease it.\n+ ///\n+ /// Expected modulator range: any finite f32. Expected gain range after\n+ /// sanitization: `[0.0, 10,000.0]`.\n+ pub fn encode_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_rate_scale(input, gains.firing_rate_scale)\n+ }\n+\n+ /// Step-wise variant of [`encode_with_modulators`](Self::encode_with_modulators).\n+ /// Uses [`encode_step_with_rate_scale`](Self::encode_step_with_rate_scale)\n+ /// for accumulator-based spike generation.\n+ pub fn encode_step_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_step_with_rate_scale(input, gains.firing_rate_scale)\n+ }\n+}\n+\n+impl Encoder for RateEncoder {\n+ fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ self.encode_with_rate_scale(input, 1.0)\n+ }\n+\n+ fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {\n+ self.encode_step_with_rate_scale(input, 1.0)\n+ }\n+\n fn reset(&mut self) {\n for acc in self.accumulators.iter_mut() {\n *acc = 0.0;\n@@ -207,4 +257,67 @@ mod tests {\n assert!(result.is_ok());\n }\n }\n+\n+ #[test]\n+ fn test_rate_encoder_modulated_step_scales_firing_rate() {\n+ let mut encoder = RateEncoder::new(0.0, 5.0, (0.0, 1.0));\n+ let modulators = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+ let gain_curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ firing_rate: Some(GainCurve::new((0.0, 1.0), (1.0, 2.0))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+\n+ let baseline = encoder.encode_step(&[1.0]);\n+ assert!(baseline.spikes.is_empty());\n+\n+ encoder.reset();\n+\n+ let boosted = encoder.encode_step_with_modulators(&[1.0], &modulators, &gain_curves);\n+ assert_eq!(boosted.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_rate_encoder_encode_with_modulators() {\n+ let mut encoder = RateEncoder::new(0.0, 10.0, (0.0, 1.0));\n+ let modulators = NeuroModulators {\n+ dopamine: 1.0,\n+ ..Default::default()\n+ };\n+ let gain_curves = NeuromodulatorGainCurves {\n+ dopamine: ModulatorGainCurves {\n+ firing_rate: Some(GainCurve::new((0.0, 1.0), (1.0, 2.0))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+\n+ let boosted = encoder.encode_with_modulators(&[1.0], &modulators, &gain_curves);\n+ assert_eq!(boosted.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_rate_encoder_step_shorter_input() {\n+ let mut encoder = RateEncoder::new(0.0, 10.0, (0.0, 1.0));\n+ // Single value against two accumulators: process channel 0, leave channel 1 untouched\n+ let output = encoder.encode_step(&[1.0]);\n+ assert_eq!(output.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_rate_encoder_zero_rate_scale_never_accumulates() {\n+ let mut encoder = RateEncoder::new(0.0, 10.0, (0.0, 1.0));\n+ for _ in 0..10_000 {\n+ let output = encoder.encode_step_with_rate_scale(&[1.0], 0.0);\n+ assert!(\n+ output.spikes.is_empty(),\n+ \"zero firing-rate scale must fully silence streaming output\"\n+ );\n+ }\n+ }\n }\ndiff --git a/src/encoders/temporal.rs b/src/encoders/temporal.rs\nindex be5359a5..1be3f739 100644\n--- a/src/encoders/temporal.rs\n+++ b/src/encoders/temporal.rs\n@@ -54,15 +54,21 @@ impl TemporalEncoder {\n change_thresholds,\n }\n }\n-}\n \n-impl Encoder for TemporalEncoder {\n- fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ fn encode_with_threshold_scale(\n+ &mut self,\n+ input: &[f32],\n+ threshold_scale: f32,\n+ ) -> EncodedOutput {\n let mut output = EncodedOutput::new();\n for (i, &value) in input.iter().enumerate() {\n if i >= self.history.len() {\n break;\n }\n+ let Ok(channel) = u16::try_from(i) else {\n+ // Remaining channels exceed u16::MAX; stop rather than wrap.\n+ break;\n+ };\n let channel_history = &mut self.history[i];\n if channel_history.len() == self.history_depth {\n channel_history.pop_front();\n@@ -78,9 +84,9 @@ impl Encoder for TemporalEncoder {\n let change = (recent_avg - older_avg).abs();\n \n for &(threshold, _spike_val) in self.change_thresholds.iter().rev() {\n- if change > threshold {\n+ if change > (threshold * threshold_scale).max(0.0) {\n output.spikes.push(SpikeEvent {\n- channel: u16::try_from(i).expect(\"channel index exceeds u16::MAX\"),\n+ channel,\n timestamp: 0, // Simplified\n polarity: true, // Or use spike_val to determine polarity/strength\n });\n@@ -91,13 +97,63 @@ impl Encoder for TemporalEncoder {\n output\n }\n \n+ /// Encode input using neuromodulator-driven gain curves.\n+ ///\n+ /// Evaluates `gain_curves` against the current `modulators` to produce\n+ /// an [`EncodingGains`], then uses the `threshold_scale` component to\n+ /// modulate the change-detection threshold. Values > 1.0 raise the\n+ /// effective threshold (less sensitive — larger changes required to\n+ /// spike); values in (0, 1) lower it (more sensitive).\n+ ///\n+ /// Input is truncated to the number of tracked channels. Expected\n+ /// modulator range: any finite f32. Expected gain range after\n+ /// sanitization: `[0.0, 10,000.0]`.\n+ pub fn encode_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ // Match encode_step_with_modulators: only process channels we track.\n+ let safe_input = if input.len() > self.history.len() {\n+ &input[..self.history.len()]\n+ } else {\n+ input\n+ };\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_threshold_scale(safe_input, gains.threshold_scale)\n+ }\n+\n+ /// Step-wise variant of [`encode_with_modulators`](Self::encode_with_modulators).\n+ /// Identical behavior, provided for API symmetry with the [`Encoder`] trait.\n+ pub fn encode_step_with_modulators(\n+ &mut self,\n+ input: &[f32],\n+ modulators: &NeuroModulators,\n+ gain_curves: &NeuromodulatorGainCurves,\n+ ) -> EncodedOutput {\n+ let safe_input = if input.len() > self.history.len() {\n+ &input[..self.history.len()]\n+ } else {\n+ input\n+ };\n+ let gains = gain_curves.evaluate(modulators);\n+ self.encode_with_threshold_scale(safe_input, gains.threshold_scale)\n+ }\n+}\n+\n+impl Encoder for TemporalEncoder {\n+ fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ self.encode_with_threshold_scale(input, 1.0)\n+ }\n+\n fn encode_step(&mut self, input: &[f32]) -> EncodedOutput {\n let safe_input = if input.len() > self.history.len() {\n &input[..self.history.len()]\n } else {\n input\n };\n- self.encode(safe_input)\n+ self.encode_with_threshold_scale(safe_input, 1.0)\n }\n \n fn reset(&mut self) {\n@@ -164,35 +220,81 @@ mod tests {\n }\n \n #[test]\n- fn test_temporal_encoder_history_limit() {\n- let mut encoder = TemporalEncoder::new(6, vec![(1.0, 1)], 1);\n- for _ in 0..10 {\n+ fn test_temporal_encoder_modulators_reduce_threshold() {\n+ let mut encoder = TemporalEncoder::new(6, vec![(4.5, 1)], 1);\n+ let modulators = NeuroModulators {\n+ tempo: 1.0,\n+ ..Default::default()\n+ };\n+ let gain_curves = NeuromodulatorGainCurves {\n+ tempo: ModulatorGainCurves {\n+ threshold: Some(GainCurve::new((0.0, 1.0), (1.0, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n+\n+ for _ in 0..3 {\n encoder.encode(&[1.0]);\n }\n- assert_eq!(encoder.history[0].len(), 6);\n- }\n+ for _ in 0..2 {\n+ encoder.encode(&[5.0]);\n+ }\n+ assert!(encoder.encode(&[5.0]).spikes.is_empty());\n \n- #[test]\n- fn test_temporal_encoder_reset() {\n- let mut encoder = TemporalEncoder::new(6, vec![(1.0, 1)], 1);\n- encoder.encode(&[1.0; 6]);\n encoder.reset();\n- assert_eq!(encoder.history[0].len(), 0);\n+\n+ for _ in 0..3 {\n+ encoder.encode_step_with_modulators(&[1.0], &modulators, &gain_curves);\n+ }\n+ for _ in 0..2 {\n+ encoder.encode_step_with_modulators(&[5.0], &modulators, &gain_curves);\n+ }\n+ let output = encoder.encode_step_with_modulators(&[5.0], &modulators, &gain_curves);\n+ assert_eq!(output.spikes.len(), 1);\n }\n \n #[test]\n- fn test_temporal_encoder_mismatched_input() {\n- let mut encoder = TemporalEncoder::new(6, vec![(1.0, 1)], 2);\n- let _output = encoder.encode(&[1.0]);\n- assert_eq!(encoder.history[0].len(), 1);\n+ fn test_temporal_encoder_encode_with_modulators() {\n+ let mut encoder = TemporalEncoder::new(6, vec![(4.5, 1)], 1);\n+ let modulators = NeuroModulators {\n+ tempo: 1.0,\n+ ..Default::default()\n+ };\n+ let gain_curves = NeuromodulatorGainCurves {\n+ tempo: ModulatorGainCurves {\n+ threshold: Some(GainCurve::new((0.0, 1.0), (1.0, 0.5))),\n+ ..Default::default()\n+ },\n+ ..Default::default()\n+ };\n \n- let output2 = encoder.encode_step(&[1.0, 2.0, 3.0]);\n- assert_eq!(output2.spikes.len(), 0);\n+ for _ in 0..3 {\n+ encoder.encode_with_modulators(&[1.0], &modulators, &gain_curves);\n+ }\n+ for _ in 0..2 {\n+ encoder.encode_with_modulators(&[5.0], &modulators, &gain_curves);\n+ }\n+ let output = encoder.encode_with_modulators(&[5.0], &modulators, &gain_curves);\n+ assert_eq!(output.spikes.len(), 1);\n+ }\n+\n+ #[test]\n+ fn test_temporal_encoder_step_longer_input() {\n+ let mut encoder = TemporalEncoder::new(6, vec![(4.5, 1)], 2);\n+ let output = encoder.encode_step(&[1.0, 2.0, 3.0]);\n+ assert!(output.spikes.len() <= 2);\n }\n \n+ #[cfg(feature = \"serde\")]\n #[test]\n- #[should_panic(expected = \"history_depth must be at least 6\")]\n- fn test_temporal_encoder_invalid_depth() {\n- let _ = TemporalEncoder::new(5, vec![], 1);\n+ fn test_temporal_serde_history_channel_too_long() {\n+ let json = r#\"{\n+ \"history\": [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]],\n+ \"history_depth\": 6,\n+ \"change_thresholds\": []\n+ }\"#;\n+ let res: Result<TemporalEncoder, _> = serde_json::from_str(json);\n+ assert!(res.is_err());\n }\n }\ndiff --git a/src/lib.rs b/src/lib.rs\nindex aa2a846c..03f732ca 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -10,12 +10,12 @@ pub mod rng;\n pub mod types;\n \n pub mod prelude {\n+ pub use crate::Encoder;\n pub use crate::encoder::*;\n pub use crate::encoders::*;\n pub use crate::modulators::*;\n pub use crate::poisson::*;\n pub use crate::types::*;\n- pub use crate::Encoder;\n }\n \n use types::EncodedOutput;\n@@ -73,4 +73,29 @@ mod tests {\n use crate::prelude::*;\n let _ = EncoderConfig::default();\n }\n+\n+ #[test]\n+ fn test_encoder_default_encode_step_delegates_to_encode() {\n+ use crate::prelude::*;\n+\n+ struct PassThrough;\n+ impl Encoder for PassThrough {\n+ fn encode(&mut self, input: &[f32]) -> EncodedOutput {\n+ let mut out = EncodedOutput::new();\n+ for (i, &v) in input.iter().enumerate() {\n+ out.spikes.push(SpikeEvent {\n+ channel: i as u16,\n+ timestamp: v as u64,\n+ polarity: true,\n+ });\n+ }\n+ out\n+ }\n+ fn reset(&mut self) {}\n+ }\n+\n+ let mut enc = PassThrough;\n+ let out = enc.encode_step(&[1.0, 2.0]);\n+ assert_eq!(out.spikes.len(), 2);\n+ }\n }\ndiff --git a/src/modulators.rs b/src/modulators.rs\nindex c36189fa..9163f1bd 100644\n--- a/src/modulators.rs\n+++ b/src/modulators.rs\n@@ -2,6 +2,18 @@ const EVENT_DOPAMINE_DECAY: f32 = 0.95;\n const CORTISOL_DECAY: f32 = 0.90;\n const ACETYLCHOLINE_DECAY: f32 = 0.99;\n const TEMPO_DECAY: f32 = 0.98;\n+/// Allow true zero gain (full silence / zero threshold). Non-finite values map\n+/// to identity; values above this cap are clamped for numerical stability.\n+const MIN_GAIN_SCALE: f32 = 0.0;\n+const MAX_GAIN_SCALE: f32 = 1e4;\n+\n+fn sanitize_gain_scale(scale: f32) -> f32 {\n+ if !scale.is_finite() {\n+ return 1.0;\n+ }\n+\n+ scale.clamp(MIN_GAIN_SCALE, MAX_GAIN_SCALE)\n+}\n \n #[derive(Debug, Clone, Copy, Default, PartialEq)]\n #[cfg_attr(feature = \"serde\", derive(serde::Serialize, serde::Deserialize))]\n@@ -21,22 +33,484 @@ impl NeuroModulators {\n }\n }\n \n+#[derive(Debug, Clone, Copy, PartialEq)]\n+#[cfg_attr(feature = \"serde\", derive(serde::Serialize))]\n+pub struct GainCurve {\n+ pub input_range: (f32, f32),\n+ pub output_range: (f32, f32),\n+}\n+\n+impl GainCurve {\n+ pub fn new(input_range: (f32, f32), output_range: (f32, f32)) -> Self {\n+ assert!(\n+ input_range.0.is_finite() && input_range.1.is_finite() && input_range.0 < input_range.1,\n+ \"input_range min must be less than max and finite\"\n+ );\n+ assert!(\n+ output_range.0.is_finite() && output_range.1.is_finite(),\n+ \"output_range values must be finite\"\n+ );\n+\n+ Self {\n+ input_range,\n+ output_range,\n+ }\n+ }\n+\n+ pub fn identity() -> Self {\n+ Self {\n+ input_range: (0.0, 1.0),\n+ output_range: (1.0, 1.0),\n+ }\n+ }\n+\n+ /// Returns whether this curve has a valid, finite, ordered input range.\n+ fn has_valid_input_range(&self) -> bool {\n+ self.input_range.0.is_finite()\n+ && self.input_range.1.is_finite()\n+ && self.input_range.0 < self.input_range.1\n+ }\n+\n+ /// Evaluate the gain curve at the given modulator level.\n+ ///\n+ /// Negative levels are clamped to `input_range.0`. NaN or non-finite\n+ /// levels return the identity gain (1.0).\n+ pub fn evaluate(&self, level: f32) -> f32 {\n+ // Guard against NaN levels and invalid ranges that can arise from\n+ // public fields or bypassed constructors (e.g. deserialization).\n+ if !level.is_finite()\n+ || !self.has_valid_input_range()\n+ || !self.output_range.0.is_finite()\n+ || !self.output_range.1.is_finite()\n+ {\n+ return 1.0;\n+ }\n+\n+ let clamped_level = level.clamp(self.input_range.0, self.input_range.1);\n+ // Use f64 for span to avoid overflow for valid f32 ranges (e.g., f32::MIN..f32::MAX).\n+ let span = (self.input_range.1 as f64) - (self.input_range.0 as f64);\n+ // span is guaranteed > 0 by has_valid_input_range\n+ let position = ((clamped_level as f64 - self.input_range.0 as f64) / span) as f32;\n+\n+ // Use lerp form to avoid overflow when output_range spans nearly f32::MAX.\n+ let raw_scale = self.output_range.0 * (1.0 - position) + self.output_range.1 * position;\n+\n+ sanitize_gain_scale(raw_scale)\n+ }\n+}\n+\n+#[cfg(feature = \"serde\")]\n+impl<'de> serde::Deserialize<'de> for GainCurve {\n+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n+ where\n+ D: serde::Deserializer<'de>,\n+ {\n+ #[derive(serde::Deserialize)]\n+ struct Helper {\n+ input_range: (f32, f32),\n+ output_range: (f32, f32),\n+ }\n+\n+ let helper = Helper::deserialize(deserializer)?;\n+\n+ if !helper.input_range.0.is_finite()\n+ || !helper.input_range.1.is_finite()\n+ || helper.input_range.0 >= helper.input_range.1\n+ {\n+ return Err(serde::de::Error::custom(\n+ \"input_range min must be less than max and finite\",\n+ ));\n+ }\n+ if !helper.output_range.0.is_finite() || !helper.output_range.1.is_finite() {\n+ return Err(serde::de::Error::custom(\n+ \"output_range values must be finite\",\n+ ));\n+ }\n+\n+ Ok(Self {\n+ input_range: helper.input_range,\n+ output_range: helper.output_range,\n+ })\n+ }\n+}\n+\n+impl Default for GainCurve {\n+ fn default() -> Self {\n+ Self::identity()\n+ }\n+}\n+\n+#[derive(Debug, Clone, Copy, PartialEq, Default)]\n+#[cfg_attr(feature = \"serde\", derive(serde::Serialize, serde::Deserialize))]\n+pub struct ModulatorGainCurves {\n+ pub threshold: Option<GainCurve>,\n+ pub sensitivity: Option<GainCurve>,\n+ pub firing_rate: Op\n\n# … truncated …\n","validation":[{"type":"test","result":"pass","detail":"* 'cargo test --locked'\n* 'cargo test --features serde --locked'\n* 'cargo clippy --all-features -- -D warnings'\n* 'cargo test --package axon-encoder --lib -- rng::tests'\n* 'cargo test --message-format=json-diagnostic-rendered-ansi --color=always --no-run --package axon-encoder --lib --profile test'\n* 'cargo bench' results:\nRunning benches/allocations.rs (target/release/deps/allocations-c1b00e672c218a6d)\nencoder,operation,scale_type,scale,allocations,bytes\nRateEncoder,encode_step,scale,256,10,32768\nRateEncoder,encode_step,scale,1024,12,131072\nRateEncoder,encode_step,scale,10000,15,1048576\nPopulationEncoder,encode,neurons,256,7,4096\nPopulationEncoder,encode,neurons,1024,8,8192\nPopulationEncoder,encode,neurons,10000,12,131072\nDeltaEncoder,encode_step,scale,256,7,4096\nDeltaEncoder,encode_step,scale,1024,9,16384\nDeltaEncoder,encode_step,scale,10000,13,262144\nTemporalEncoder,encode_step,scale,256,7,4096\nTemporalEncoder,encode_step,scale,1024,9,16384\nTemporalEncoder,encode_step,scale,10000,13,262144\nPredictiveEncoder,encode_step,scale,256,7,4096\nPredictiveEncoder,encode_step,scale,1024,9,16384\nPredictiveEncoder,encode_step,scale,10000,13,262144\nPoissonEncoder,encode,steps,10,1,10\nPoissonEncoder,encode,steps,100,1,100\nPoissonEncoder,encode,steps,1000,1,1000\nRunning benches/encoders.rs (target/release/deps/encoders-42839d8a3d62c3e8)\nRateEncoder::encode/256 time: [603.59 ns 604.36 ns 605.05 ns]\nchange: [+0.5317% +0.8188% +1.0737%] (p = 0.00 < 0.05)\nChange within noise threshold.\nRateE"},{"type":"ci","result":"pass","detail":"Qodana for Rust=success, codecov/patch=success, qodana=success, Code Coverage=success, Validate, Test & Doc=success, qodana=success"},{"type":"ci","result":"pass","detail":"combined_status=success"},{"type":"other","result":"pass","detail":"review_apps: Codacy Static Code Analysis=success, Gitar=success, Kilo Code Review=success"}],"outcome":"merged","training_use":"review-to-patch","issue_context":"Issue #26: feat(modulators): Implement dynamic neuromodulator gain curves\n\n## Overview\n\nExtend `modulators.rs` to support real-time dynamic modulation of encoding thresholds, sensitivity, and firing rates based on dopamine/acetylcholine level streams.\n\n## Details\n\n- Refactor `modulators.rs` to support dynamic gain adjustment.\n- Allow downstream learning frameworks (`plasticity-lab`, `neuromod`) to adaptively tune sensory sensitivity during training/inference.\n\n— Antigravity (Gemini 3.5 Flash)\n\n* add public gain-curve and gain-composition types in `modulators.rs`\n* add opt-in neuromodulated encode helpers for rate, delta, temporal, predictive, and population encoders\n* add focused scaling tests plus serde coverage for the new modulation API","review_signals":[{"author":"gemini-code-assist[bot]","comment":"## Code Review\n\nThis pull request introduces neuromodulation capabilities across various encoders (DeltaEncoder, PopulationEncoder, PredictiveEncoder, RateEncoder, and TemporalEncoder) by adding support for dynamic gain curves that scale threshold, sensitivity, and firing rate parameters based on neuromodulator levels. It also adds corresponding unit and serialization tests. A potential panic vulnerability was identified in `GainCurve::evaluate` where invalid or non-finite deserialized `input_range` values could cause `clamp` to panic, which can be mitigated by adding robust validation checks."},{"author":"gemini-code-assist[bot]","comment":"![security-high](https://www.gstatic.com/codereviewagent/security-high-priority.svg) ![high](https://www.gstatic.com/codereviewagent/high-priority.svg)\n\nSince `GainCurve` has public fields and derives `Deserialize` (when the `serde` feature is enabled), it is possible to construct or deserialize a `GainCurve` with invalid or non-finite `input_range` values (e.g., `input_range.0 >= input_range.1` or containing `NaN`). If this occurs, calling `level.clamp(self.input_range.0, self.input_range.1)` will panic because `clamp` requires `min <= max` and finite bounds. Adding a robust guard to check these conditions and return a default scale of `1.0` prevents unexpected panics in production.\n\n```suggestion\n pub fn evaluate(&self, level: f32) -> f32 {\n if !level.is_finite()\n || !self.input_range.0.is_finite()\n || !self.input_range.1.is_finite()\n || self.input_range.0 >= self.input_range.1\n {\n return 1.0;\n }\n```","suggestion":"pub fn evaluate(&self, level: f32) -> f32 {\n if !level.is_finite()\n || !self.input_range.0.is_finite()\n || !self.input_range.1.is_finite()\n || self.input_range.0 >= self.input_range.1\n {\n return 1.0;\n }"},{"author":"rmems","comment":"**Addressed in `7fcaac847c6908db737a9a7f960acd23492e5cc0`**\n\n`GainCurve::evaluate` now rejects non-finite or non-increasing `input_range` (returns identity scale 1.0) before division, so deserialization cannot cause NaN/Inf positions.\n\nGrok Build agent: Grok 4.5"},{"author":"rmems","comment":"**Addressed in `7fcaac847c6908db737a9a7f960acd23492e5cc0`**\n\nAdded finite/ordered range guards in `evaluate` so `clamp` never panics on invalid public-field or deserialized curves; returns 1.0 for invalid state.\n\nGrok Build agent: Grok 4.5"},{"author":"rmems","comment":"**Addressed in `7fcaac847c6908db737a9a7f960acd23492e5cc0`**\n\nCustom `Deserialize` for `GainCurve` rejects reversed/non-finite ranges at load time; runtime evaluate also guards as defense in depth.\n\nGrok Build agent: Grok 4.5"},{"author":"rmems","comment":"**Addressed in `7fcaac847c6908db737a9a7f960acd23492e5cc0`**\n\n`MIN_GAIN_SCALE` is now `0.0`, so a curve that evaluates to zero truly zeros rate increments and no longer slowly accumulates via `1e-4`.\n\nGrok Build agent: Grok 4.5"},{"author":"rmems","comment":"**Addressed in `7fcaac847c6908db737a9a7f960acd23492e5cc0`**\n\nZero/non-positive sensitivity short-circuits `PopulationEncoder` encoding (no spikes) instead of widening the Gaussian.\n\nGrok Build agent: Grok 4.5"},{"author":"rmems","comment":"**Addressed in `7fcaac847c6908db737a9a7f960acd23492e5cc0`**\n\nTrue zero gain outputs are allowed (`MIN_GAIN_SCALE = 0.0`); non-finite scales still map to identity.\n\nGrok Build agent: Grok 4.5"}],"quality_score":0.95}

@cubic-dev-ai cubic-dev-ai Bot Jul 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Validation evidence for PRs #37 and #50 is silently cut mid-token, so consumers cannot distinguish a complete passing report from partial text. Preserve a complete summary or append an explicit truncation marker when applying the 1,500-character limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At datasets/jsonl/limen-axon-encoder-v0.jsonl:

<comment>Validation evidence for PRs #37 and #50 is silently cut mid-token, so consumers cannot distinguish a complete passing report from partial text. Preserve a complete summary or append an explicit truncation marker when applying the 1,500-character limit.</comment>
Fix with cubic

Comment thread scripts/lib/bots.py Outdated
Comment thread scripts/lib/bots.py Outdated
Comment thread datasets/cards/limen-axon-encoder-trajectories-v0.md
Comment thread datasets/cards/limen-axon-encoder-trajectories-v0.md
Comment thread datasets/cards/limen-axon-encoder-trajectories-v0.md Outdated
Comment thread STATUS.md Outdated
Comment thread datasets/cards/limen-axon-encoder-v0.json Outdated
Comment thread scripts/lib/bots.py
# for keyword "summary", but "Manual verification" matches "verification".
without_parens = re.sub(r"\([^)]*\)", " ", title)
without_parens = re.sub(r"\s+", " ", without_parens).strip()
return bool(re.search(rf"\b{re.escape(kw)}\b", without_parens))

@cubic-dev-ai cubic-dev-ai Bot Jul 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Validation under a common plural heading such as ## Test plans is no longer collected, so a body-only passing test record can become “No structured validation evidence collected.” Consider recognizing plural title-token forms explicitly while retaining the parenthetical/substring safeguards.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/lib/bots.py, line 166:

<comment>Validation under a common plural heading such as `## Test plans` is no longer collected, so a body-only passing test record can become “No structured validation evidence collected.” Consider recognizing plural title-token forms explicitly while retaining the parenthetical/substring safeguards.</comment>

<file context>
@@ -111,8 +140,34 @@ def _heading_level(line: str) -> int:
+    # for keyword "summary", but "Manual verification" matches "verification".
+    without_parens = re.sub(r"\([^)]*\)", " ", title)
+    without_parens = re.sub(r"\s+", " ", without_parens).strip()
+    return bool(re.search(rf"\b{re.escape(kw)}\b", without_parens))
+
+
</file context>
Fix with cubic

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

I've successfully fixed all 10 issues from PR #16:

Documentation fixes:

  1. ✅ Added data-policy cross-reference to limen-axon-encoder card metadata
  2. ✅ Added Quality column (0.95) to PR table showing curated scores
  3. ✅ Added License/provenance section documenting MIT license and derivation policy
  4. ✅ Fixed schema URL to use correct blob/main/schemas path

Data quality fixes:
5. ✅ Added truncation markers ([…]) for validation evidence exceeding 1,500 characters in JSONL and future pipeline runs

Bot filtering improvements:
6. ✅ Anchored Codex footer regex to line start (prevents mid-line matches)
7. ✅ Improved Gemini sunset detection to require Gemini+lifecycle coupling (preserves endpoint sunset notes)
8. ✅ Added plural validation heading recognition (test plans, validations, verifications)

Config security:
9. ✅ Extended dotenv exclusion to .env.*.local patterns while preserving .env.example

Housekeeping:
10. ✅ Updated STATUS.md timestamp to 2026-07-24 for consistency with manifest

Verification status:
✅ All tests passing (52 passed)
✅ JSONL validation passing (strict-policy)
✅ No new lint errors introduced

The changes maintain backward compatibility and strengthen the dataset quality requirements.

Pushed commits to feat/limen-axon-encoder-v0.

- Add data-policy cross-ref, Quality column, License section to card
- Fix schema URL to use blob/main path
- Add truncation markers for validation evidence
- Anchor Codex footer regex, improve Gemini sunset detection
- Recognize plural validation headings (test plans, validations)
- Extend dotenv exclusion to .env.*.local patterns
- Update STATUS.md timestamp to 2026-07-24
@rmems rmems self-assigned this Jul 31, 2026
@rmems
rmems merged commit be0451d into main Jul 31, 2026
6 checks passed
@rmems rmems added this to the v0.3 Limen-Neural Wave A milestone Jul 31, 2026
rmems pushed a commit that referenced this pull request Aug 2, 2026
…alidation

Addresses review findings on #17.

Linked-issue provenance:
- Drop self-referential linked issues. Issues and PRs share one number
  space, so a synthesized /issues/<pr> URL resolves back to the PR itself.
  This is what put issues/26 on the PR #26 record instead of real issue
  provenance.
- Add LINKED_ISSUE_OVERRIDE for PR bodies that name their originating
  issue without a GitHub close keyword ("Implements GitHub #22",
  "for issues #16, #22, and #27", "addressing issues #28 and #20").
  parse_linked_issue_numbers only recognises close keywords by design, so
  PRs #29/#33/#43 shipped with the PR URL as their only source.

Expected-failure validation:
- A negative-test line ("Manual invalid schema fixture confirms ingest
  failure behavior") matched the fail-word heuristic and stored PR #24's
  test result as `fail` despite every listed command and CI passing.
  Fail words are now checked per occurrence, so a deliberate failure no
  longer inverts the result while a real one alongside it still does.

Dataset: source_urls corrected on records 26/29/33/43, PR #24 test result
set to pass, manifest source_url_count/sha256/bytes regenerated.
rmems pushed a commit that referenced this pull request Aug 2, 2026
…ATUS date

Two of four findings from the CodeRabbit review of a562309.

The shortlist table said "Closes #22" for #26 and "Closes #16, #22, #27"
for #29, but those numbers come from LINKED_ISSUE_OVERRIDE, whose stubs
carry closed_by_pr: False precisely because the PR bodies use reference
wording ("Implements GitHub #22"), not close keywords. The doc therefore
contradicted the dataset it describes. Changed to "References", matching
the "Advances" wording already used for the other override-backed rows.

STATUS.md still read "Last updated: 2026-07-24" while recording the
2026-08-02 extraction.

Also added the missing docstring on `_override_key`.

The other two findings are not changes: the `.env.` prefilter token is
already covered (".env" is a substring of ".env.production.local", so the
prefilter fires and the file is filtered — verified), and review-signal
dedupe is issue #18, deliberately deferred. Both answered on their threads.
rmems added a commit that referenced this pull request Aug 2, 2026
* feat: grok-ozempic-v0 trajectories (revised 7-PR shortlist)

Extracts the grok-ozempic v0 dataset for GH #11, with the shortlist
re-scored against what the pipeline actually keeps rather than raw
comment volume.

Shortlist changes:
- add #43 (quantize-goz1 CLI) and #33 (test refactor) — both fill the
  previously empty review-to-patch bucket; #33 also supplies the only
  refactor task_type in the set
- drop #8: yields 2 review signals, one being "@copilot Make changes to
  the pull request"
- defer #42: 51 kept signals collapse to 8 identical ack bodies, and it
  is Python against a Rust card with no language_by_pr override

Measured by replaying every PR through lib/bots.is_bot_user and
lib.normalize.extract_review_signals (cap 8). rmems is the repo's only
human account, so most signal comes via the gemini/codex allowlist.

Also adds a task_type override for #26, whose non-conventional title
("Verify grok-ozempic aligns...") fell through to "other" despite adding
grok1_inventory.rs and alignment.rs.

7 records, all 8 review signals, quality 0.90-0.95, strict schema +
policy validation clean. Raw records stay local per .gitignore.

Refs #11

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(normalize): repair linked-issue provenance and expected-failure validation

Addresses review findings on #17.

Linked-issue provenance:
- Drop self-referential linked issues. Issues and PRs share one number
  space, so a synthesized /issues/<pr> URL resolves back to the PR itself.
  This is what put issues/26 on the PR #26 record instead of real issue
  provenance.
- Add LINKED_ISSUE_OVERRIDE for PR bodies that name their originating
  issue without a GitHub close keyword ("Implements GitHub #22",
  "for issues #16, #22, and #27", "addressing issues #28 and #20").
  parse_linked_issue_numbers only recognises close keywords by design, so
  PRs #29/#33/#43 shipped with the PR URL as their only source.

Expected-failure validation:
- A negative-test line ("Manual invalid schema fixture confirms ingest
  failure behavior") matched the fail-word heuristic and stored PR #24's
  test result as `fail` despite every listed command and CI passing.
  Fail words are now checked per occurrence, so a deliberate failure no
  longer inverts the result while a real one alongside it still does.

Dataset: source_urls corrected on records 26/29/33/43, PR #24 test result
set to pass, manifest source_url_count/sha256/bytes regenerated.

* fix(normalize): require explicit expected-failure markers; case-fold override keys

Both findings from the Codex review of 3401a79.

Expected-failure cue was too loose (P1). The reporting-verb alternative
matched any of confirms/verified/checks/ensures followed by a fail word
within 80 characters, so ordinary failure reports were inverted to
`pass` — reproduced with "CI checks failed on Linux" and "Verified that
cargo test failed". Every alternative now carries an explicit
intentionality marker (expected/intended/intentional/deliberate, "fails
as expected", failure behaviour|mode|case|path|handling, negative test).
PR #24 still classifies as pass via "ingest failure behavior".

Override keys are now case-folded (P2). GitHub repo slugs are
case-insensitive but `parse_repo` preserves whatever the CLI was given,
so collecting with `--repo RMEMS/GROK-OZEMPIC` missed every lookup in
TASK_TYPE_OVERRIDE, DOMAIN_OVERRIDE, TRAINING_USE_OVERRIDE and the new
LINKED_ISSUE_OVERRIDE — silently relabelling PR #26 and dropping the
issue provenance just restored. All four tables now resolve through
`_override_key`.

Datasets are unchanged; both fixes are guarded by new regression tests.

* fix: drop local agent config from patches; tighten expected-failure cue

Five findings from the Codex review of 5a98c3c.

Local agent/tracker state in curated patches (P1). PR #26's record
embedded full diffs for .beads/ (config, git hooks, tracker exports) and
.claude/settings.json — 12 of its 25 files. The .beads/issues.jsonl
export carried a personal email in an `owner` field, so a curated
artifact shipped contributor PII alongside machine-local config that
docs/data-policy.md excludes. Extended `_is_noise_patch_path`, which
already exists for exactly this, with a directory-prefix rule.

Expected-failure cue still lacked intent (P1). The
`failure behavior|mode|case|path|handling` alternative enclosed the only
fail token in "CI showed failure behavior on Linux", inverting it to
pass. Replaced with a deliberately-invalid-fixture cue, which is what
PR #24 actually reads on; a bare failure noun is no longer a cue.

Self-references from the raw record (P2). The guard only covered
synthesized stubs, so a pre-collected same-repo issue matching the PR
number survived into `seen` and out again. Now filtered on the way in.

Copilot reviewer counted as CI (P2). `copilot-pull-request-reviewer`
missed `_REVIEW_APP_CHECK_MARKERS`, so reviewer automation bolstered a
passing CI result. Added the marker and moved it to review_apps.

Stale indexes (P2). README called grok-ozempic "shortlist only" and
STATUS listed the extraction as an open gap with the obsolete six-PR
roadmap including dropped #8.

PR #26 regenerated: patch_chars 67711 -> 50149, before_context file
count 25 -> 13, manifest sha256/bytes updated. No other record changes.

* fix(normalize): filter noise dirs in diff prefilter; require asserted intent

Two P1s from the Codex review of 79191d7, both on code from that commit.

The diff prefilter was basename-only. `_filter_noise_from_diff` returns
early unless a `_NOISE_PATCH_BASENAMES` token appears in the text, so the
new `_NOISE_PATCH_DIRS` rule never ran on the inline/sidecar path — the
one the collector normally supplies. A diff touching only .beads/ or
.claude/ files would have kept its hunks intact. PR #26 was filtered only
because its .beads/.gitignore happens to contain the substring ".env".
The prefilter now scans for directories as well as basenames.

The fixture cue accepted a bad adjective as intent. "Invalid fixture was
repaired, but cargo test failed on Linux" matched, because the gap
between "fixture" and the fail word allowed any non-period characters and
swallowed the clause boundary. The fixture must now be the subject of a
confirming verb whose object is the failure (confirms/verifies/triggers/
demonstrates/shows/proves), with \w+ gaps that cannot cross a comma.

PR #24 still classifies as pass on "invalid schema fixture confirms
ingest failure behavior". Datasets are unchanged; both fixes affect
future normalization only, and both are pinned by regression tests.

* fix(normalize): nested agent state, exact copilot check, unmet expected failures

Four findings from the Codex review of b6dd6ae.

Nested agent state (P1). `_NOISE_PATCH_DIRS` was matched as a root
prefix, so a monorepo's pkg/.claude/settings.json or
workspace/.beads/config.yaml kept its hunk. Now matched as a path
component, with `beads/` and `.beadsfoo/` still excluded.

Copilot marker was a generic product term (P2). Substring "copilot"
would also classify real CI such as "Copilot integration tests" as a
review app, dropping it from ci_checks — and if it were the only
successful check the record would report no validation evidence at all.
Narrowed to the exact `copilot-pull-request-reviewer` check name.

Expected failure that never happened (P2). "The invalid fixture was
expected to fail, but it passed unexpectedly" describes a negative test
that accepted input it should have rejected, but classified as pass. The
cue proves intent, not that the failure occurred. A contradiction now
forces `fail`. This one predates the expected-failure work — verified
that disabling `_fail_words_all_expected` gives the same result — but it
is a real gap in the same area.

Also: `_fail_words_all_expected` returned True when a section had cue
spans and no fail words at all, vacuously. Now False.

Truncation inventory (P2). docs/source-repos.md claimed #11, #24 and #26
all truncate at the 96 KiB budget. Only #11 does and carries the footer;
#24 (77,970) and #26 (50,149) are complete. Corrected, and noted that
#26 is smaller than its raw diff because agent state is filtered.

* fix(normalize): recognize absence-of-failure wording as a failed negative test

Codex P2 on b4de520. The contradiction matcher added in that commit only
covered `passed`/`succeeded` and the exact phrase `did not fail`, so "The
expected failure did not occur" and "was not observed" still suppressed
to pass — reproduced both.

Generalized to absence wording: did not / does not / was not / were not /
never, followed within two words by fail / occur / observed / seen /
triggered / raised / reported / happen.

The contradiction only applies when an expected-failure cue is also
present, so ordinary resolved-failure prose ("previously failed tests are
now passing") is unaffected — covered by the test alongside the new
cases. PR #24 still classifies as pass. Datasets unchanged.

* fix(normalize): scope expected-failure contradiction to a single sentence

Codex P2 on a562309. The contradiction check tested the cue and the
absence wording independently against the whole section, so an unrelated
later clause matched: "Invalid fixture fails as expected. The warning did
not occur" forced a passing validation to `fail`. Reproduced, along with
the list-item form.

`_expected_failure_was_contradicted` now splits on sentence boundaries
and newlines and requires both to appear in the same part, so the
contradiction must actually negate the failure it sits with. Same-sentence
cases are unaffected.

This one produced a false `fail` rather than a false `pass`, so no shipped
record could have been inflated by it. Datasets unchanged; PR #24 still
classifies as pass.

* docs: align shortlist wording with closed_by_pr semantics; refresh STATUS date

Two of four findings from the CodeRabbit review of a562309.

The shortlist table said "Closes #22" for #26 and "Closes #16, #22, #27"
for #29, but those numbers come from LINKED_ISSUE_OVERRIDE, whose stubs
carry closed_by_pr: False precisely because the PR bodies use reference
wording ("Implements GitHub #22"), not close keywords. The doc therefore
contradicted the dataset it describes. Changed to "References", matching
the "Advances" wording already used for the other override-backed rows.

STATUS.md still read "Last updated: 2026-07-24" while recording the
2026-08-02 extraction.

Also added the missing docstring on `_override_key`.

The other two findings are not changes: the `.env.` prefilter token is
already covered (".env" is a substring of ".env.production.local", so the
prefilter fires and the file is filtered — verified), and review-signal
dedupe is issue #18, deliberately deferred. Both answered on their threads.

* fix(normalize): treat soft-wrapped lines as one statement

Codex P2 on 6a59e9d, a regression from the sentence-scoping fix in
76ba370. Splitting on every newline broke Markdown soft wraps, so "The
expected failure\nwas not observed" landed the cue and its contradiction
in separate parts and the contradiction never fired — a false `pass`.
Reproduced both wrapped forms.

_SENTENCE_SPLIT now breaks only on real statement boundaries: sentence
punctuation, a blank line, or the start of a list item. A bare newline is
no longer a boundary.

Both the wrapped forms and the genuine boundaries (sentence, list item,
paragraph) are pinned by tests. Datasets unchanged; PR #24 still passes.

* chore(ci): drop .gitlab-ci.yml

The project builds on GitHub Actions; the GitLab pipeline duplicated the
same lint/test/validate stages and had drifted — its validate stage ran
`validate_jsonl.py` without `--strict-policy`, so it was a weaker gate
than the GitHub one it mirrored.

* docs: align #29 domain with the card; correct #33 review-density wording

Two findings from the CodeRabbit review of 5f4d741.

The shortlist table gave #29 a domain of "validation, CI" while both
datasets/cards/grok-ozempic-v0.json (domain_by_pr) and the record itself
say "validation" — the only row of the seven that disagreed. Dropped the
", CI"; the CI aspect is already carried by the Title ("Docker CI") and
the Signal column ("Docker + cargo audit"), so nothing is lost.

#33 was described as "highest human review density in the repo", but the
measured table counts signals *after* the bot filter, and this section
states rmems is the repo's only human account — most of those 70 kept
signals are gemini/codex. Reworded to "highest filtered review-signal
density", which is what was actually measured.

* fix: strip linked Macroscope notes; allow modifiers in expected-failure cue

Two findings from the Codex review of 695e068.

Macroscope summaries were never stripped. `_MACROSCOPE_NOTE` required the
literal phrase "Macroscope summarized", but the attribution renders as
`<a href="...">Macroscope</a> summarized` — name and verb are not
adjacent, so the pattern never matched. PR #24's validation event
therefore carried the bot's entire change summary after its six real
commands, truncated mid-footer at the 1500-char limit. The regex now
tolerates markup between name and verb; record #24's test detail drops
from 1504 chars to 358 and ends cleanly at its Closes list.

Expected-failure cue required `expected` to sit immediately before the
fail word, so "the expected test failure was observed" was recorded as a
failed validation. Now allows up to two modifiers. Bounded deliberately:
"We expected a clean run but saw 3 test failures" and "Not expected: the
suite failed on main" both still classify as fail, and both are pinned.

Manifest sha256/bytes regenerated.

* fix(bots): scope Macroscope stripping to one note; revert cue widening

Both findings from the Codex review of 2075610 were regressions in that
same commit.

The Macroscope pattern was destroying content. Its leading `.*?` reached
forward for the attribution, so a PR body with a legitimate `[!NOTE]`
before a later bot summary lost the human note *and* every validation
line between them. Reproduced: human note and `cargo test` commands both
vanished. Now matches one `> [!NOTE]` blockquote run at a time and drops
it only when that block carries the attribution, mirroring how
_GEMINI_IMPORTANT_BLOCK already works.

The expected-failure cue widening is reverted. Allowing modifiers between
the intent marker and the fail word let "The CI was expected to be
failing on Linux until the runner is repaired" — a known-broken run —
suppress to `pass`. That is the fabricating direction, so the round-9
improvement is not worth its cost; its test is removed with it.

Known limitation, deliberately left: "intentionally failing on Windows"
still suppresses to `pass`. Restricting the cue to negative-test subjects
fixes it but then reads "expected to fail, but it passed unexpectedly" as
`pass` instead. Measured both — every configuration retains exactly one
false pass; only removing the heuristic has none.

* fix(normalize): filter .kilo/ agent state; recognize asserted negative tests

Two findings from the Codex review of 39a7a1a.

.kilo/ was missing from _NOISE_PATCH_DIRS (P1), so a collected PR
carrying local Kilo agent state would leak it into a curated patch — the
same class as the .beads/ and .claude/ leak fixed earlier, which shipped
a contributor email. This repo's own .codacy.yml already excludes
.kilo/**, so the classification was settled, just not applied here.
Near-misses `kilo/`, `.kilofoo/` and `src/kilo_backend.rs` stay unfiltered.

The `negative test` cue only ever spanned those two words, so
"Negative test confirms failure behavior" left the fail token outside any
cue span and recorded a successful negative test as `fail`. Extended
through an explicit observing/confirming verb — the same shape as the
fixture alternative — rather than by loosening the intent marker, which
is what produced last round's false pass. "Negative test suite reported 3
failures", "Negative tests failed on Windows" and "Negative test added.
CI checks failed on Linux" all still classify as fail, and all three are
pinned.

Full 13-case matrix green. 75 tests, ruff clean, --strict-policy clean.

* fix(bots): keep a separator when dropping a Macroscope note

CodeRabbit finding on 2b7b340, and a real defect in the note-scoping fix
from 39a7a1a. `_NOTE_BLOCK` consumes the newline preceding the block, so
replacing an attributed block with "" spliced the line before the note
onto the line after it. Reproduced: two distinct validation commands
merged into

    - cargo test --features cli- invalid fixture confirms failure behavior

Returns "\n" instead. Non-Macroscope blocks are still returned verbatim,
so unattributed notes are untouched either way.

The regression test asserts the surrounding lines survive as two separate
entries, not just that the bot text is gone — the previous test passed
while the splice was happening because it only checked for absence.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
rmems added a commit that referenced this pull request Aug 15, 2026
* data: extract myelin-accelerator-v0 trajectories (#21)

Add five high-signal rmems/myelin-accelerator PRs (#26, #18, #22, #7, #6)
as myelin-accelerator-v0: card, JSONL, manifest, and docs promotion from
"candidate next" to a full shortlist section.

- Buckets: #26/#18 feature, #22 repair, #7/#6 review-to-patch
- Domains: gpu-compute / cuda; all 8 unique review signals
- LINKED_ISSUE_OVERRIDE for #18#16/13/12/10/11 and #26#9
- TASK_TYPE_OVERRIDE for WIP #6/#7 titles → feature

Closes #21

Co-Authored-By: Grok Build Agent: Grok 4.5 <noreply@x.ai>

* fix: myelin-accelerator card metadata, STATUS, override tests

- related_issues.myelin_accelerator includes #21 (CUDA quality-gate issue)
- STATUS: extract complete; drop stale "Land #21" roadmap item
- Behavior tests: normalize_record URLs for PRs 18/26; task_type_for #6/#7

Co-Authored-By: Grok Build Agent: Grok 4.5 <noreply@x.ai>

---------

Co-authored-by: Grok Build Agent: Grok 4.5 <noreply@x.ai>
rmems added a commit that referenced this pull request Aug 20, 2026
* data: extract theseus-quarry-v0 telemetry trajectories

Shortlist five merged PRs (#13, #9, #12, #11, #8). Defer #16 and #18.
Per-PR domain and task_type labels live on the dataset card only.

Co-authored-by: Raul Montoya Cardenas  <montoyaraul34@gmail.com>

* test: prove Theseus-Quarry domain_by_pr beats a planted DOMAIN_OVERRIDE

Temporarily insert conflicting shared-dict rows for #8/#9/#12, assert the
card still wins, then restore the table so the test stays isolated.

Co-authored-by: Raul Montoya Cardenas  <montoyaraul34@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request size:L This PR changes 100-499 lines, ignoring generated files

Projects

No open projects
Status: Done

Development

Successfully merging this pull request may close these issues.

Extract limen-axon-encoder-v0 trajectories (axon-encoder #37, #50, #41) Document Limen-Neural v0 extraction shortlist in source-repos + cards

1 participant