Skip to content

Phase 17: forgelm audit --workers N + determinism contract - #26

Closed
cemililik wants to merge 2 commits into
developmentfrom
closure/faz17-workers-determinism
Closed

cemililik wants to merge 2 commits into
developmentfrom
closure/faz17-workers-determinism

Conversation

@cemililik

@cemililik cemililik commented May 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds split-level parallelism to the audit pipeline (`--workers N` flag) while pinning byte-identical output across worker counts via the test suite. CI gates that compare the audit-report hash continue to work after operators switch from `--workers 1` to `--workers 4`.

```shell

Sequential (default; pre-Phase-17 behaviour, byte-identical)

forgelm audit data/

Phase 17: split-level parallelism on multi-split corpora

forgelm audit data/ --workers 4
```

Speed-up scales with the number of splits — `--workers 3` on a `train` / `validation` / `test` corpus typically yields a near-linear speed-up.

Determinism contract

The merge step that builds the final report stays single-threaded so `data_audit_report.json` is byte-identical between worker counts. The only field that varies is `generated_at` (wall-clock timestamp). Pinned by 18 tests in `tests/test_data_audit_workers.py`.

Files

File Change
`forgelm/data_audit/_orchestrator.py` New `_process_split_for_pool` wrapper + `workers` param on `audit_dataset`; sequential path unchanged
`forgelm/cli/_argparse_types.py` New `_positive_int` validator (rejects 0 / negatives)
`forgelm/cli/_parser.py` `--workers N` flag on the audit subparser
`forgelm/cli/subcommands/_audit.py` `workers` threaded through `_run_data_audit` + `_run_audit_cmd`
`forgelm/cli/init.py` `_positive_int` re-export
`tests/test_data_audit_workers.py` (new, 320 lines) 18 tests, three test classes
`docs/guides/data_audit.md` Run-it example + CLI reference + determinism explanation
`CHANGELOG.md` Phase 17 entry

Test plan

  • `ruff format . && ruff check .` clean
  • `pytest tests/ -q` → 1031 passed, 13 skipped (1013 → 1031, +18 new)
  • Coverage 67.94% (≥40% threshold; unchanged within rounding)
  • `forgelm audit --help` shows `--workers` flag
  • `forgelm audit /nonexistent --workers 0` exits non-zero at parse time
  • Byte-identical JSON between workers=1 and workers={2,4} (parametrised regression test)
  • Library `audit_dataset(workers=0/-1/bool/str)` raises typed `ValueError`

Risk

Low. Strictly additive: `workers=1` is the default and reproduces the pre-Phase-17 single-process path exactly. The merge step is single-threaded so the on-disk artefact is invariant — operators who pin the report hash in CI continue to pass without changing their gate.

Closes

  • `closure-plan-202604300906.md` §7 Phase 17 (F-performance-104)

🤖 Generated with Claude Code

Summary by Sourcery

Introduce configurable split-level parallelism to the data audit pipeline while preserving deterministic, byte-identical reports across worker counts.

New Features:

  • Add a workers parameter to the data audit API and CLI to run split-level processing in parallel using multiple processes.
  • Expose a --workers N flag on the forgelm audit subcommand with validation for positive integer values.

Enhancements:

  • Ensure audit report aggregation remains single-threaded and ordered so output is deterministic across different worker counts.
  • Add input validation for the workers parameter in the audit API to reject non-integer, boolean, and <1 values.
  • Refine audit orchestrator internals with a pool-safe helper for split processing to support multiprocessing.

Documentation:

  • Update data audit documentation and changelog to describe the new --workers option, performance characteristics, and determinism contract.

Tests:

  • Add a dedicated regression test suite for workers that validates byte-identical JSON output across worker counts, component-level invariants, edge cases, and CLI behaviour of the --workers flag.

Closure plan §7 Phase 17 deliverable. Adds split-level parallelism
to the audit pipeline while pinning byte-identical output across
worker counts via the test suite.

Implementation:

- forgelm/data_audit/_orchestrator.py
  - new module-level _process_split_for_pool wrapper so worker
    pickling stays spawn-method safe (lambdas + functools.partial
    over keyword-only args do not pickle cleanly across spawn)
  - audit_dataset(workers: int = 1) parameter:
    workers=1 keeps the pre-Phase-17 single-process path exactly,
    workers>1 distributes per-split processing over min(workers,
    len(splits)) Pool workers; merge step stays single-threaded so
    the final report is byte-identical across worker counts.
  - Pre-flight validation: workers must be positive int; rejects
    bool / non-int / negative with a typed ValueError.
- forgelm/cli/_argparse_types.py
  - new _positive_int type validator (rejects 0 / negatives at
    argparse parse time, separate from _non_negative_int which
    accepts 0).
- forgelm/cli/_parser.py
  - --workers N flag on the audit subparser, default 1, type
    _positive_int.
- forgelm/cli/subcommands/_audit.py
  - workers param threaded through _run_data_audit + _run_audit_cmd
    so the CLI flag reaches audit_dataset.
- forgelm/cli/__init__.py
  - re-export _positive_int for tests / monkeypatch.

Determinism contract (the whole point of this phase):

  data_audit_report.json is byte-identical between --workers 1 and
  --workers 4. The only field that varies is generated_at (wall-clock
  timestamp). CI gates that compare the audit-report hash across
  runs continue to work after operators switch worker counts.

Tests (tests/test_data_audit_workers.py — 18 cases):

- byte-identical JSON for workers in {2, 4} vs sequential baseline
  (parametrised, generated_at stripped before compare)
- per-split lang_sample byte-identical (the field most likely to
  diverge under non-deterministic ordering)
- pii_summary / pii_severity identical across worker counts
- near_duplicate_summary identical (with injected duplicate rows)
- total_samples identical
- secrets_summary identical (with injected fake AKIA token)
- single-split corpus tolerates workers > 1 (no parallelism
  available, must still produce a valid report)
- default-when-omitted equals workers=1 (backwards compatibility)
- workers in {0, -1, -10} raises typed ValueError
- workers="four" / workers=True raises typed ValueError
- CLI: --workers flag advertised in audit --help with default 1
- CLI: --workers 0 rejected at parse time with clear error
- CLI: --workers -2 rejected at parse time
- CLI: omitted --workers produces same on-disk JSON as --workers 1
  (subprocess end-to-end smoke)

Documentation:

- docs/guides/data_audit.md
  - Run-it section: --workers 4 example next to existing dedup-
    method / quality-filter examples
  - CLI reference: --workers N added to the flag list
  - dedicated section explaining the determinism contract + how the
    speed-up scales with number of splits, not row count
- CHANGELOG.md [Unreleased]: full Phase 17 entry

Test count: 1013 -> 1031 (+18). Coverage 67.94% (unchanged within
rounding; new code adds tests + reduces cli/_argparse_types coverage
gap). ruff format/check clean.

Closes: closure-plan-202604300906.md §7 Phase 17 (F-performance-104)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented May 2, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implements split-level parallelism for the data audit pipeline via a new --workers CLI flag and workers parameter on audit_dataset, while preserving byte-identical output across worker counts and pinning this determinism with a dedicated test suite, plus associated CLI, docs, and changelog updates.

Sequence diagram for forgelm audit --workers N parallel pipeline with deterministic merge

sequenceDiagram
    actor Operator
    participant CLI as ForgelmCLI
    participant Argparse as ArgparseParser
    participant AuditCmd as AuditSubcommand_run_audit_cmd
    participant Orchestrator as audit_dataset
    participant Pool as MultiprocessingPool
    participant Worker as _process_split_for_pool

    Operator->>CLI: forgelm audit data/ --workers 4
    CLI->>Argparse: parse args (includes workers)
    Argparse-->>CLI: Namespace(workers=4,...)

    CLI->>AuditCmd: _run_audit_cmd(args, output_format)
    AuditCmd->>AuditCmd: extract workers = getattr(args, workers, 1)
    AuditCmd->>Orchestrator: audit_dataset(source, ..., workers=4)

    Orchestrator->>Orchestrator: validate workers is positive int
    Orchestrator->>Orchestrator: splits_paths, resolution_notes = _resolve_input(source)
    Orchestrator->>Orchestrator: compute effective_workers = min(workers, len(splits_paths))

    alt effective_workers > 1
        Orchestrator->>Pool: create Pool(effective_workers)
        Orchestrator->>Pool: map(_process_split_for_pool, pool_args)
        Pool->>Worker: _process_split_for_pool(args_tuple)
        Worker->>Worker: unpack (split_name, path, kwargs)
        Worker->>Orchestrator: _process_split(split_name, path, **kwargs)
        Orchestrator-->>Worker: outcome
        Worker-->>Pool: outcome
        Pool-->>Orchestrator: outcomes_list (order preserved)
        Orchestrator->>Orchestrator: outcomes_iter = zip(splits_paths.keys(), outcomes_list)
    else sequential path
        loop for split_name, path in splits_paths.items()
            Orchestrator->>Orchestrator: outcome = _process_split(split_name, path, **kwargs)
            Orchestrator-->>Orchestrator: yield split_name, outcome via _sequential_outcomes
        end
        Orchestrator->>Orchestrator: outcomes_iter = _sequential_outcomes()
    end

    loop deterministic merge over outcomes_iter
        Orchestrator->>Orchestrator: update splits_info, signatures_by_split
        Orchestrator->>Orchestrator: accumulate totals (samples, tokens, errors)
    end

    Orchestrator-->>AuditCmd: AuditReport
    AuditCmd->>AuditCmd: _atomic_write_json(target_path, asdict(report))
    AuditCmd-->>CLI: write deterministic data_audit_report.json
    CLI-->>Operator: exit code 0, report hash stable across worker counts
Loading

File-Level Changes

Change Details Files
Add split-level multiprocessing to audit_dataset with a determinism-preserving merge step and input validation for the workers parameter.
  • Introduce _process_split_for_pool module-level helper to make per-split processing picklable for multiprocessing.Pool.map.
  • Extend audit_dataset with a workers parameter defaulting to 1 and validate it as a positive int, rejecting non-ints and bools with ValueError.
  • Compute an effective_workers count bounded by the number of splits and use a multiprocessing.Pool when >1, otherwise run a sequential generator, while always merging outcomes in split order to preserve deterministic aggregates.
forgelm/data_audit/_orchestrator.py
Expose the workers control via the CLI with strict positive-int parsing and thread it through to the audit implementation.
  • Add a _positive_int argparse type that enforces integers >=1 and surfaces friendly errors for invalid values.
  • Register a --workers N flag on the audit subparser with help text explaining performance characteristics and determinism.
  • Plumb workers through _run_data_audit and _run_audit_cmd so CLI invocations pass the parsed value into audit_dataset.
  • Re-export _positive_int from the cli package for test access.
forgelm/cli/_argparse_types.py
forgelm/cli/_parser.py
forgelm/cli/subcommands/_audit.py
forgelm/cli/__init__.py
Add a focused regression test suite to pin the workers determinism contract and CLI behavior.
  • Seed a deterministic three-split corpus helper and utilities to write JSONL fixtures for tests.
  • Assert byte-identical audit JSON (modulo generated_at) across workers={1,2,4} and explicit equality of lang_sample and key summary fields.
  • Verify edge cases including single-split corpora, default workers behavior, and that invalid workers values raise ValueError in the library API.
  • Add CLI smoke tests ensuring --workers appears in help, default semantics match workers=1, and invalid values are rejected at parse time.
tests/test_data_audit_workers.py
Document the new parallelism option and record the change in the changelog.
  • Update the data audit guide with an example run using --workers 4, CLI synopsis changes, and an explanation of the determinism contract and performance characteristics.
  • Add a Phase 17 changelog entry describing split-level parallelism, determinism guarantees, test coverage, CLI exposure, and the new helper function.
docs/guides/data_audit.md
CHANGELOG.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented May 2, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a08793f2-3fd5-489c-9196-aa948687fe31

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch closure/faz17-workers-determinism

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 and usage tips.

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Add split-level parallelism to audit pipeline with determinism contract

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Adds split-level parallelism to audit pipeline via --workers N flag
• Guarantees byte-identical JSON output across worker counts via single-threaded merge
• Implements 18 regression tests pinning determinism contract across worker configurations
• Introduces _positive_int argparse validator rejecting zero and negative values
Diagram
flowchart LR
  CLI["CLI: --workers N flag"]
  Parser["argparse: _positive_int validator"]
  Audit["audit_dataset: workers param"]
  Validate["Validation: workers >= 1"]
  Sequential["workers=1: Sequential path"]
  Parallel["workers>1: Pool.map per-split"]
  Merge["Single-threaded merge"]
  JSON["Byte-identical JSON output"]
  
  CLI --> Parser
  Parser --> Audit
  Audit --> Validate
  Validate --> Sequential
  Validate --> Parallel
  Sequential --> Merge
  Parallel --> Merge
  Merge --> JSON
Loading

Grey Divider

File Changes

1. forgelm/cli/__init__.py ✨ Enhancement +1/-0

Export positive integer validator

• Re-exports new _positive_int validator for test monkeypatching

forgelm/cli/init.py


2. forgelm/cli/_argparse_types.py ✨ Enhancement +17/-0

Add positive integer argparse type validator

• Adds _positive_int() function that rejects values < 1 at argparse parse time
• Mirrors _non_negative_int() but enforces >= 1 constraint for --workers flag

forgelm/cli/_argparse_types.py


3. forgelm/cli/_parser.py ✨ Enhancement +16/-1

Register --workers flag on audit subcommand

• Imports _positive_int validator from _argparse_types
• Registers --workers N flag on audit subparser with default value 1
• Includes comprehensive help text explaining Phase 17 parallelism and determinism contract

forgelm/cli/_parser.py


View more (5)
4. forgelm/cli/subcommands/_audit.py ✨ Enhancement +3/-0

Thread workers parameter through CLI layer

• Adds workers: int = 1 parameter to _run_data_audit() function signature
• Threads workers parameter through to audit_dataset() call
• Extracts workers from parsed args in _run_audit_cmd() with default fallback

forgelm/cli/subcommands/_audit.py


5. forgelm/data_audit/_orchestrator.py ✨ Enhancement +63/-13

Implement split-level parallelism with determinism contract

• Imports multiprocessing module for Pool-based parallelism
• Adds _process_split_for_pool() module-level wrapper function for picklable worker dispatch
• Adds workers: int = 1 parameter to audit_dataset() with validation rejecting non-positive
 integers and non-int types
• Implements conditional logic: workers > 1 uses multiprocessing.Pool.map() with ordered outcome
 preservation; workers == 1 uses sequential generator
• Bounds effective workers to min(workers, len(splits_paths)) to avoid idle processes
• Merge step remains single-threaded to guarantee byte-identical JSON across worker counts

forgelm/data_audit/_orchestrator.py


6. tests/test_data_audit_workers.py 🧪 Tests +361/-0

Add comprehensive determinism contract test suite

• New 361-line test module with 18 test cases across three test classes
• TestWorkersDeterminism: validates byte-identical JSON and lang_sample across workers={1,2,4}
• TestWorkersComponentInvariants: pins identical PII/secrets/near-duplicate/quality summaries
 across worker counts
• TestWorkersEdgeCases: covers single-split corpus handling, default behavior, and validation of
 invalid worker values
• TestWorkersCLI: smoke tests for --workers flag help text, parse-time rejection of 0/negative
 values, and CLI end-to-end behavior

tests/test_data_audit_workers.py


7. CHANGELOG.md 📝 Documentation +28/-0

Document Phase 17 workers feature in changelog

• Adds Phase 17 section documenting split-level parallelism feature
• Lists determinism contract guarantees and test coverage (18 tests)
• Documents new _positive_int validator and _process_split_for_pool helper
• References updated operator documentation in docs/guides/data_audit.md

CHANGELOG.md


8. docs/guides/data_audit.md 📝 Documentation +18/-0

Document workers flag and determinism contract

• Adds forgelm audit data/ --workers 4 example to Run-it section
• Extends CLI reference with [--workers N] flag documentation
• Adds detailed explanation of determinism contract: byte-identical JSON across worker counts,
 single-threaded merge, and CI gate compatibility
• Clarifies speed-up scales with split count not row count

docs/guides/data_audit.md


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. TR guide missing --workers ☑ 📘 Rule violation ⚙ Maintainability
Description
User-facing docs were updated in English for Phase 17 (--workers N) but the Turkish mirrored guide
was not updated, violating the bilingual documentation policy. This creates doc drift and
inconsistent operator guidance between EN and TR.
Code

docs/guides/data_audit.md[R42-43]

+# Phase 17: split-level parallelism for multi-split corpora
+forgelm audit data/ --workers 4
Evidence
The Phase 17 --workers documentation was added to the English data audit guide, but the Turkish
mirrored guide (data_audit-tr.md) still lacks the corresponding update in its Run section,
indicating the EN↔TR mirror requirement is not met.

CLAUDE.md
docs/guides/data_audit.md[42-43]
docs/guides/data_audit-tr.md[19-40]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
English user-facing docs add Phase 17 `--workers N` guidance, but the Turkish mirrored guide is missing the same update.
## Issue Context
Compliance requires EN↔TR mirrored updates for user-facing documentation.
## Fix Focus Areas
- docs/guides/data_audit.md[42-43]
- docs/guides/data_audit.md[485-485]
- docs/guides/data_audit.md[510-523]
- docs/guides/data_audit-tr.md[19-60]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Schema order nondeterminism 🐞 Bug ≡ Correctness
Description
When audit_dataset(workers>1) runs splits in separate processes, schema column ordering can differ
from workers=1 because _record_schema_for_dict iterates a frozenset(row.keys()). This can
change splits[*].columns / schema_drift_columns ordering and break the PR’s “byte-identical
across worker counts” contract for multi-column rows.
Code

forgelm/data_audit/_orchestrator.py[R323-331]

+    effective_workers = min(workers, len(splits_paths)) if splits_paths else 1
+    if effective_workers > 1:
+        # Pool.map preserves input order so ``outcomes`` lines up with
+        # ``splits_paths.items()`` 1:1.  We bound the pool to len(splits_paths)
+        # — extra workers would idle since the unit of work is one split.
+        pool_args = [(name, path, split_kwargs) for name, path in splits_paths.items()]
+        with multiprocessing.Pool(effective_workers) as pool:
+            outcomes_list = pool.map(_process_split_for_pool, pool_args)
+        outcomes_iter = zip(splits_paths.keys(), outcomes_list)
Evidence
The PR introduces a multiprocessing execution mode (multiprocessing.Pool(...).map(...)) so
per-split outcome.info is computed in child processes and then copied into the final report
unchanged. _record_schema_for_dict builds seen_columns by iterating a frozenset of keys, whose
iteration order depends on per-process hash randomization; _populate_schema_block then emits
info['columns'] from that order, and the orchestrator stores it directly in splits_info, so
worker-count changes can change the serialized output.

forgelm/data_audit/_orchestrator.py[323-342]
forgelm/data_audit/_aggregator.py[88-93]
forgelm/data_audit/_aggregator.py[189-199]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`audit_dataset(workers>1)` computes per-split `info` in child processes. `_record_schema_for_dict` iterates `frozenset(row.keys())`, making `columns` ordering dependent on per-process hash seed; this can cause `workers=1` vs `workers>1` to produce different JSON.
### Issue Context
The final report uses `outcome.info` verbatim per split, so any ordering differences inside `_audit_split` propagate to the on-disk report.
### Fix Focus Areas
- forgelm/data_audit/_aggregator.py[88-93]
- forgelm/data_audit/_aggregator.py[189-199]
- tests/test_data_audit_workers.py[44-57]
### Suggested change
Keep `frozenset(row.keys())` for counting keysets, but populate `seen_columns` with a deterministic iteration (e.g., `for col in sorted(row.keys()): ...` or `for col in row.keys(): ...`). Add/extend a workers determinism fixture to include multi-column rows and assert `columns` ordering is identical across worker counts.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Tests not byte-identical ☑ 🐞 Bug ⚙ Maintainability
Description
TestWorkersDeterminism claims to enforce byte-identical output but compares json.loads(...)
results, which can’t detect byte-level differences (e.g., key ordering/formatting) that would change
a hash-based CI gate. This leaves the stated determinism contract under-tested.
Code

tests/test_data_audit_workers.py[R84-104]

+    @pytest.mark.parametrize("worker_count", [2, 4])
+    def test_audit_json_byte_identical_to_sequential(self, tmp_path: Path, worker_count: int) -> None:
+        corpus = _seed_three_split_corpus(tmp_path)
+
+        baseline_dir = tmp_path / "out-w1"
+        baseline_dir.mkdir()
+        baseline_json = _audit_to_canonical_json(corpus, workers=1, output_dir=baseline_dir)
+
+        parallel_dir = tmp_path / f"out-w{worker_count}"
+        parallel_dir.mkdir()
+        parallel_json = _audit_to_canonical_json(corpus, workers=worker_count, output_dir=parallel_dir)
+
+        # ``generated_at`` is wall-clock; strip it from both before
+        # comparing.  Everything else must match byte-for-byte.
+        baseline = json.loads(baseline_json)
+        parallel = json.loads(parallel_json)
+        baseline.pop("generated_at", None)
+        parallel.pop("generated_at", None)
+        assert baseline == parallel, (
+            f"audit JSON differs between workers=1 and workers={worker_count}; the determinism contract is broken"
+        )
Evidence
The test loads the report JSON into Python objects, removes generated_at, and compares dict
equality rather than comparing normalized raw file bytes. Meanwhile the writer uses `json.dump(...,
indent=2, ensure_ascii=False) without sort_keys`, so byte-level ordering/format differences are
plausible even when parsed objects are equal.

tests/test_data_audit_workers.py[84-104]
forgelm/data_audit/_orchestrator.py[157-168]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The determinism test currently asserts semantic equality (decoded JSON objects) rather than byte-level equality of the on-disk file after normalizing `generated_at`.
### Issue Context
The PR description/CHANGELOG/docs repeatedly claim the report is **byte-identical** across worker counts (except `generated_at`). If CI gates compare hashes of the file, semantic-only tests may miss a real regression.
### Fix Focus Areas
- tests/test_data_audit_workers.py[84-104]
- forgelm/data_audit/_orchestrator.py[157-168]
### Suggested change
In the test, normalize `generated_at` in the *raw JSON string* (e.g., parse both, set `generated_at` to a constant, then re-serialize using the same parameters as `_atomic_write_json`), and compare the resulting strings exactly. This will actually pin byte-identical output rather than just structural equality.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@codacy-production

codacy-production Bot commented May 2, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 48 complexity · 0 duplication

Metric Results
Complexity 48
Duplication 0

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.

@sourcery-ai sourcery-ai 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.

Hey - I've found 1 issue, and left some high level feedback:

  • The workers validation logic in audit_dataset duplicates and slightly diverges from the _positive_int argparse type; consider extracting a shared internal helper (that also handles the bool case explicitly) so CLI and library callers always use exactly the same validation contract.
  • In audit_dataset, the sequential branch builds _sequential_outcomes() only to immediately iterate it; simplifying this back to a direct for loop (like the pre-Phase-17 code) would reduce indirection and make the control flow easier to follow.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `workers` validation logic in `audit_dataset` duplicates and slightly diverges from the `_positive_int` argparse type; consider extracting a shared internal helper (that also handles the `bool` case explicitly) so CLI and library callers always use exactly the same validation contract.
- In `audit_dataset`, the sequential branch builds `_sequential_outcomes()` only to immediately iterate it; simplifying this back to a direct `for` loop (like the pre-Phase-17 code) would reduce indirection and make the control flow easier to follow.

## Individual Comments

### Comment 1
<location path="tests/test_data_audit_workers.py" line_range="284" />
<code_context>
+        assert "--workers" in result.stdout
+        assert "default: 1" in result.stdout.lower() or "(default: 1" in result.stdout
+
+    def test_cli_workers_zero_rejected_at_parse_time(self) -> None:
+        """``--workers 0`` exits with argparse usage-error rather than
+        propagating into the audit pipeline."""
</code_context>
<issue_to_address>
**suggestion (testing):** Add a CLI test for non-integer `--workers` to exercise `_positive_int`'s type error path

Current tests cover `--workers 0` and negative values, but `_positive_int` also has a path for non-integer input (`argparse.ArgumentTypeError("invalid integer: ...")`) that isn’t exercised. Please add a CLI test for a non-integer value to cover that branch, for example by running the module with `--workers notanint` and asserting a non-zero exit code and that `"invalid integer"` appears in `stderr`. This will lock down `_positive_int`’s behavior and error messaging.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

assert "--workers" in result.stdout
assert "default: 1" in result.stdout.lower() or "(default: 1" in result.stdout

def test_cli_workers_zero_rejected_at_parse_time(self) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add a CLI test for non-integer --workers to exercise _positive_int's type error path

Current tests cover --workers 0 and negative values, but _positive_int also has a path for non-integer input (argparse.ArgumentTypeError("invalid integer: ...")) that isn’t exercised. Please add a CLI test for a non-integer value to cover that branch, for example by running the module with --workers notanint and asserting a non-zero exit code and that "invalid integer" appears in stderr. This will lock down _positive_int’s behavior and error messaging.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces split-level parallelism to the forgelm audit pipeline via a new --workers N flag. The implementation utilizes multiprocessing.Pool to process dataset splits in parallel while maintaining a determinism contract that ensures byte-identical JSON reports regardless of the worker count. Key changes include the addition of a _positive_int argparse validator, orchestrator updates to handle process pooling, and a comprehensive new test suite in tests/test_data_audit_workers.py. Feedback was provided to simplify the sequential execution path in the orchestrator by replacing a nested function with a more concise generator expression.

Comment thread forgelm/data_audit/_orchestrator.py Outdated
Comment on lines +334 to +338
def _sequential_outcomes() -> Any:
for split_name, path in splits_paths.items():
yield split_name, _process_split(split_name, path, **split_kwargs)

outcomes_iter = _sequential_outcomes()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The nested function _sequential_outcomes is somewhat verbose for a simple sequential iteration. You can achieve the same result more concisely using a generator expression, which improves readability and avoids the overhead of defining a nested function.

Suggested change
def _sequential_outcomes() -> Any:
for split_name, path in splits_paths.items():
yield split_name, _process_split(split_name, path, **split_kwargs)
outcomes_iter = _sequential_outcomes()
outcomes_iter = (
(split_name, _process_split(split_name, path, **split_kwargs))
for split_name, path in splits_paths.items()
)

Comment thread docs/guides/data_audit.md
Comment on lines +323 to +331
effective_workers = min(workers, len(splits_paths)) if splits_paths else 1
if effective_workers > 1:
# Pool.map preserves input order so ``outcomes`` lines up with
# ``splits_paths.items()`` 1:1. We bound the pool to len(splits_paths)
# — extra workers would idle since the unit of work is one split.
pool_args = [(name, path, split_kwargs) for name, path in splits_paths.items()]
with multiprocessing.Pool(effective_workers) as pool:
outcomes_list = pool.map(_process_split_for_pool, pool_args)
outcomes_iter = zip(splits_paths.keys(), outcomes_list)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Schema order nondeterminism 🐞 Bug ≡ Correctness

When audit_dataset(workers>1) runs splits in separate processes, schema column ordering can differ
from workers=1 because _record_schema_for_dict iterates a frozenset(row.keys()). This can
change splits[*].columns / schema_drift_columns ordering and break the PR’s “byte-identical
across worker counts” contract for multi-column rows.
Agent Prompt
### Issue description
`audit_dataset(workers>1)` computes per-split `info` in child processes. `_record_schema_for_dict` iterates `frozenset(row.keys())`, making `columns` ordering dependent on per-process hash seed; this can cause `workers=1` vs `workers>1` to produce different JSON.

### Issue Context
The final report uses `outcome.info` verbatim per split, so any ordering differences inside `_audit_split` propagate to the on-disk report.

### Fix Focus Areas
- forgelm/data_audit/_aggregator.py[88-93]
- forgelm/data_audit/_aggregator.py[189-199]
- tests/test_data_audit_workers.py[44-57]

### Suggested change
Keep `frozenset(row.keys())` for counting keysets, but populate `seen_columns` with a deterministic iteration (e.g., `for col in sorted(row.keys()): ...` or `for col in row.keys(): ...`). Add/extend a workers determinism fixture to include multi-column rows and assert `columns` ordering is identical across worker counts.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Closes the two CRITICAL findings, three HIGH findings, and one
HIGH bot comment.

CRITICAL fixes:

- F-26-01 (CRITICAL): test_lang_sample_byte_identical was vacuous
  — both sides returned None because lang_sample is the in-memory
  _StreamingAggregator field, never serialised.  Renamed to
  test_languages_top3_byte_identical and now compares the actual
  on-disk languages_top3 list (the persisted derivative operators
  and CI gates can see).
- F-26-02 (CRITICAL): test_audit_json_byte_identical_to_sequential
  compared parsed dicts (tolerates key ordering / whitespace /
  Unicode normalisation / float repr drift), not bytes.  Now adds
  _file_sha256() helper that strips generated_at textually and
  hashes the rest — exactly what an Article 10 governance-bundle CI
  gate compares.  Dict equality kept as a secondary looser check so
  both layers regress-trip simultaneously.

HIGH fixes:

- F-26-03 (HIGH): _orchestrator now pins multiprocessing context to
  "spawn" (was: default Pool() = fork on Linux + spawn on macOS /
  Windows; Linux CI never exercised the spawn path).  Spawn gives
  every worker a fresh interpreter — no shared
  langdetect.DetectorFactory state, no copied open file handles.
- F-26-04 (HIGH): new TestWorkersWithMinHash exercises the
  byte-identical contract on dedup_method="minhash" + workers > 1
  (the path operators explicitly recommend for >50K-row corpora).
  Skipped when datasketch optional extra is not installed.
- F-26-05 (HIGH): new TestWorkersErrorPropagation —
  test_sequential_split_failure_propagates (per-split failure
  bubbles up unchanged) + test_parallel_path_does_not_silently_complete
  (a missing-file split surfaces inline as read_failed without
  hanging or killing the whole report).
- F-26-06 (HIGH): new test_split_iteration_order_pinned asserts
  list(report.splits.keys()) == ["train", "validation", "test"] so a
  future refactor that swaps the dict for a set or alphabetises
  silently breaks the byte-identical contract.

MEDIUM / NIT:

- gemini bot comment: removed nested _sequential_outcomes function
  in favour of a single generator expression in the else branch
  (cleaner + same semantics).
- Added INFO log on entering parallel path so operators can verify
  --workers 4 actually engaged (F-26-08).
- sourcery bot: new test_cli_workers_non_integer_rejected_at_parse_time
  exercises the _positive_int "invalid integer" error path.
- qodo bot: docs/guides/data_audit-tr.md updated with --workers
  example + dedicated section (was: only EN side updated in original
  Phase 17 PR).

DEFERRED (out of this PR's scope):

- F-26-07 (MEDIUM): effective_workers upper-bound — the
  min(workers, len(splits_paths)) clamp already caps at split count
  (typically 3); 1M-process loophole only triggers if a future
  refactor adds per-row parallelism.  Logged for that follow-up.
- F-26-09 (MEDIUM): _process_split_for_pool relocation — keeps
  spawn-method picklability concerns local to the orchestrator;
  splitting would require careful re-export.
- F-26-10 (MEDIUM): fake AKIA test fixture — gitleaks did not flag
  in pre-commit; revisited if it ever does.
- F-26-11 (MEDIUM): component-invariant tests calling output_dir —
  TestWorkersDeterminism tests already exercise the on-disk path
  via _audit_to_canonical_json; component tests are deliberately
  in-memory for speed.
- F-26-12..16 LOW/NIT: backlog.

Tests: 1031 → 1035 (+4 net: +6 new tests, -2 vacuous removed).
Skipped: 13 → 14 (datasketch-gated minhash×workers test).
ruff format/check clean. Coverage 67.94% → 67.91% (within rounding).

Closes (review IDs): F-26-01..06 + gemini-sequential + sourcery-non-int
                     + qodo-tr-guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented May 2, 2026

Copy link
Copy Markdown

@cemililik

Copy link
Copy Markdown
Collaborator Author

Wave 2a Round-1 multi-agent review — fixes pushed

All CRITICAL + HIGH findings from the multi-agent review (and the relevant inline bot comments) have been addressed in the latest commit on this branch. See the commit message for the per-finding mapping (review IDs F-26-*, gemini bot IDs, qodo bot IDs, sourcery bot IDs).

Per-PR test counts:

ruff format/check clean across all five PRs. Cross-PR XPR-01 concern (audit-log parser proliferation) closed in PR #25 via the new forgelm/cli/subcommands/_audit_log_reader.py shared module that _approve.py and _approvals.py now both delegate to (Phase 21 forgelm purge will use the same module).

Items deferred to follow-up PRs (with rationale) are listed under DEFERRED in each commit message.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@cemililik: Thank you for the detailed summary of fixes pushed across all five PRs. The Wave 2a Round-1 coverage improvements on PR #26 — byte-identical SHA-256 pinning, spawn-context pin, minhash×workers regression, error propagation, and split-order determinism — look like solid additions to the determinism contract. I'll kick off a fresh review of the latest commit now.

[review]

@cemililik

Copy link
Copy Markdown
Collaborator Author

Superseded by #28 (consolidated Wave 2a integration). All commits from this branch are merged into closure/wave2a-integration and the Wave-2a Round-1 review fixes ride on top there. Closing without merge to keep history single-stream.

@cemililik cemililik closed this May 4, 2026
@cemililik
cemililik deleted the closure/faz17-workers-determinism branch May 10, 2026 11:35
cemililik added a commit that referenced this pull request Jun 15, 2026
_read_jsonl_split decoded each line twice (replace-decode for text, then strict-decode to infer decode_error). Switch to strict-first with an errors='replace' fallback only on UnicodeDecodeError — behaviour-identical, one decode on the common path. Existing TestJsonlDecodeErrorDetection guards both branches. CodeRabbit #26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant