Skip to content

Feat/universal aggregate metrics#115

Open
hayleypark wants to merge 37 commits into
devfrom
issue94_doc_update
Open

Feat/universal aggregate metrics#115
hayleypark wants to merge 37 commits into
devfrom
issue94_doc_update

Conversation

@hayleypark

@hayleypark hayleypark commented Apr 24, 2026

Copy link
Copy Markdown

Issue #, if available: #94

Description of changes:

  • Updated Hungarian Matching for Structured Lists

    • Updated algorithm to always match single-item lists to count as TP if similarity score exceeds match_threshold
    • Refined logic to ensure matched pairs are always available for aggregate metrics
  • Updated StructuredListComparator

    • When either or both instances for comparison are empty, properly account for child leaf node metrics.
    • Simplified calculating nested field metrics to reuse the structured model's compare recursive logic instead of re-inventing the wheel
    • Threshold gating applies recursively at every nesting level (matching dev behavior). No class-level mutable state.
    • Zero-similarity pairs (similarity == 0.0) are treated as unmatched (FN+FA) at all levels — object, per-field overall, and per-field aggregate.
  • Dispatcher Logic Refactoring:

    • Added helper method to identify List[StructuredModel] fields specifically (_is_structured_list_field)
    • When a field is a structured list, all value types (including null) are now properly delegated to StructuredListComparator
  • Improved Test Cases for Aggregate Functionality:

    • Update exiting test cases for correct universal aggregate metrics calculation
    • Added a new test cases for universal aggregate metrics calculation
    • Restored TestWeightedOverallScoreParity (bulk-vs-per-doc parity coverage)
  • Enhanced Bulk Evaluator with Aggregate Support:

    • Added include_aggregates parameter to BulkStructuredModelEvaluator
    • New recall_with_fd parameter for flexible recall calculations
    • AggregateConfusionMatrixAccumulator is included in the default accumulators list (added by @sromoam). No extra configuration needed.
    • recall_with_fd is a constructor arg on the accumulator (e.g., AggregateConfusionMatrixAccumulator(recall_with_fd=True)), not on the evaluator.
    • Aggregate metrics are accumulated separately from regular confusion matrix metrics
    • Enhanced pretty printing to display both regular and aggregate metrics
  • Dead Code Removal:

    • Removed ~290 lines of unused methods (_handle_hierarchical_field, _handle_non_hierarchical_field, _add_derived_metrics_recursively, _should_use_hierarchical_structure, leaf-seeding helpers) and stale references in comparison_dispatcher.py and architecture.md.
  • Update documentation accordingly: docs/docs/Advanced/aggregate-metrics.md; docs/docs/Advanced/threshold-gated-evaluation.md

  • Assumption: For aggregate metrics, all matched items (regardless of the similarity score) are considered. Zero-similarity pairs are excluded (treated as unmatched, not matched).

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

sujimart and others added 24 commits April 16, 2026 17:25
* Mainly, thresholding gating logic applicable to object level metrics should not affect aggregation metric. The threshold gated evaluation is edited according.
* Also, the aggregate-metrics documentation is updated to capture more details on how aggregate is computed for each node type.
… after "Key Features", before the two examples. "Calculation Summary" follows the examples.

Replaced the confusing shorthand bullet list in Example 2 with a table that maps each item (AAA/BBB/CCC) to its object-level classification and per-field outcomes, then shows how the column sums produce items.aggregate. Updated the notebook to match.
…model_evaluator to optionally include aggregate results and optionally use recall with fd
…rics to handle aggregation of good and bad matches. The logic is changed from reliance on _handle_hierarchical_field to compare_recursive in comparison_engine.
…turning the field_details. This will allow the aggregate metrics to correctly trickle up.
1) In comparison_dispatcher.py, inside dispatch_field_comparison function, use handle_list_field_dispatch only when simple list. When it's structured list, let compare_struct_list_with_scores handle structured list.
3) In structured_list_comparator.py, _calculate_nested_field_metrics, simplied the code. No threshold of matched pairs is applied. Field details are calculated for all matched pairs and matched objects.
Note that the following update is no longer applies as the method is removed in dev:
2) In structured_list_comparator.py, in _handle_struct_list_empty_cases only handle when both are empty. Otherwise, run through the logic. This is necessary to get the field level metrics.
… (e.g., when field value is list versus string)

2) Removed checking for _aggregate flag in test_json_schema_field_converter.py

3) One major change in many test files:
* Update incorrect test cases to pass all test cases in test_structured_model_evaluator_nested.py. Some test cases still fail in test_ecommerce_orders_aggregate_comprehensive.py, which require additional code change. Clean up some print statements, unused tests.

* Update comments per feedback

---------

Co-authored-by: Hayley Park <parhyunj@amazon.com>
* Updating the logic in dispatcher:
1) Moving from runtime type checking to static field definition checking. Doing this by moving away from _should_use_hierarchical_structure and using _is_structured_list_field instead. This is done mainly for list fields, both primitive and structured. Future changes could expand to other types as needed.
2) when the field is a structured list, delegate handling all types of values whether null or not, to StructuredListComparator

Updated structured_model:
Add helper method to check if a field is specifically List[StructuredModel], complementing _is_list_field() which checks for any list type.
* Update the early exit logic in Hungarian matching to make sure there is always matched pairs for aggregate metrics

---------

Co-authored-by: Hayley Park <parhyunj@amazon.com>
* Previously when a structured list was empty, it would be counted as one TN for aggregate at the object level but aggregation may miss this (because overall metrics are skipped when there are child instances). This change creates a field dict to capture TN at the child leaf node, so that aggregate metric can trickle up correctly.

2) Updated tests to reflect changes due to above logic
* Update hungarian to better handle single-item lists. Always match single-item lists, count as tp if the item similarity score is higher than match_threshold. Update fn count for a test_hungarian test case

* Remove an unused, commented out variable
…per.is_effectively_null_for_primitives in comparison_dispatcher
@github-actions

Copy link
Copy Markdown

Security Scan Results

ASH Security Scan Report

  • Report generated: 2026-04-24T21:43:43+00:00
  • Time since scan: 1 minute

Scan Metadata

  • Project: ASH
  • Scan executed: 2026-04-24T21:41:58+00:00
  • ASH version: 3.1.2

Summary

Scanner Results

The table below shows findings by scanner, with status based on severity thresholds and dependencies:

  • Severity levels:
    • Suppressed (S): Findings that have been explicitly suppressed and don't affect scanner status
    • Critical (C): Highest severity findings that require immediate attention
    • High (H): Serious findings that should be addressed soon
    • Medium (M): Moderate risk findings
    • Low (L): Lower risk findings
    • Info (I): Informational findings with minimal risk
  • Duration (Time): Time taken by the scanner to complete its execution
  • Actionable: Number of findings at or above the threshold severity level that require attention
  • Result:
    • PASSED = No findings at or above threshold
    • FAILED = Findings at or above threshold
    • MISSING = Required dependencies not available
    • SKIPPED = Scanner explicitly disabled
    • ERROR = Scanner execution error
  • Threshold: The minimum severity level that will cause a scanner to fail
    • Thresholds: ALL, LOW, MEDIUM, HIGH, CRITICAL
    • Source: Values in parentheses indicate where the threshold is set:
      • global (global_settings section in the ASH_CONFIG used)
      • config (scanner config section in the ASH_CONFIG used)
      • scanner (default configuration in the plugin, if explicitly set)
  • Statistics calculation:
    • All statistics are calculated from the final aggregated SARIF report
    • Suppressed findings are counted separately and do not contribute to actionable findings
    • Scanner status is determined by comparing actionable findings to the threshold
Scanner Suppressed Critical High Medium Low Info Actionable Result Threshold
bandit 0 0 0 0 3526 0 0 PASSED MEDIUM (global)
cdk-nag 0 0 0 0 0 0 0 PASSED MEDIUM (global)
cfn-nag 0 0 0 0 0 0 0 MISSING MEDIUM (global)
checkov 0 1 0 0 0 0 1 SKIPPED MEDIUM (global)
detect-secrets 0 0 0 0 0 0 0 PASSED MEDIUM (global)
grype 0 0 0 0 0 0 0 MISSING MEDIUM (global)
npm-audit 0 0 0 0 0 0 0 PASSED MEDIUM (global)
opengrep 0 0 0 0 0 0 0 MISSING MEDIUM (global)
semgrep 0 0 0 0 0 0 0 PASSED MEDIUM (global)
syft 0 0 0 0 0 0 0 MISSING MEDIUM (global)

Top 1 Hotspots

Files with the highest number of security findings:

Finding Count File Location
1 .github/workflows/workflow.yml

Detailed Findings

Show 1 actionable findings

Finding 1: CKV2_GHA_1

  • Severity: HIGH
  • Scanner: checkov
  • Rule ID: CKV2_GHA_1
  • Location: .github/workflows/workflow.yml:11-12

Description:
Ensure top-level permissions are not set to write-all


Report generated by Automated Security Helper (ASH) at 2026-04-24T21:43:44+00:00

@github-actions

Copy link
Copy Markdown

Security Scan Results

ASH Security Scan Report

  • Report generated: 2026-04-24T21:46:08+00:00
  • Time since scan: 2 minutes

Scan Metadata

  • Project: ASH
  • Scan executed: 2026-04-24T21:44:06+00:00
  • ASH version: 3.1.2

Summary

Scanner Results

The table below shows findings by scanner, with status based on severity thresholds and dependencies:

  • Severity levels:
    • Suppressed (S): Findings that have been explicitly suppressed and don't affect scanner status
    • Critical (C): Highest severity findings that require immediate attention
    • High (H): Serious findings that should be addressed soon
    • Medium (M): Moderate risk findings
    • Low (L): Lower risk findings
    • Info (I): Informational findings with minimal risk
  • Duration (Time): Time taken by the scanner to complete its execution
  • Actionable: Number of findings at or above the threshold severity level that require attention
  • Result:
    • PASSED = No findings at or above threshold
    • FAILED = Findings at or above threshold
    • MISSING = Required dependencies not available
    • SKIPPED = Scanner explicitly disabled
    • ERROR = Scanner execution error
  • Threshold: The minimum severity level that will cause a scanner to fail
    • Thresholds: ALL, LOW, MEDIUM, HIGH, CRITICAL
    • Source: Values in parentheses indicate where the threshold is set:
      • global (global_settings section in the ASH_CONFIG used)
      • config (scanner config section in the ASH_CONFIG used)
      • scanner (default configuration in the plugin, if explicitly set)
  • Statistics calculation:
    • All statistics are calculated from the final aggregated SARIF report
    • Suppressed findings are counted separately and do not contribute to actionable findings
    • Scanner status is determined by comparing actionable findings to the threshold
Scanner Suppressed Critical High Medium Low Info Actionable Result Threshold
bandit 0 0 0 0 3526 0 0 PASSED MEDIUM (global)
cdk-nag 0 0 0 0 0 0 0 PASSED MEDIUM (global)
cfn-nag 0 0 0 0 0 0 0 MISSING MEDIUM (global)
checkov 0 1 0 0 0 0 1 SKIPPED MEDIUM (global)
detect-secrets 0 0 0 0 0 0 0 PASSED MEDIUM (global)
grype 0 0 0 0 0 0 0 MISSING MEDIUM (global)
npm-audit 0 0 0 0 0 0 0 PASSED MEDIUM (global)
opengrep 0 0 0 0 0 0 0 MISSING MEDIUM (global)
semgrep 0 0 0 0 0 0 0 PASSED MEDIUM (global)
syft 0 0 0 0 0 0 0 MISSING MEDIUM (global)

Top 1 Hotspots

Files with the highest number of security findings:

Finding Count File Location
1 .github/workflows/workflow.yml

Detailed Findings

Show 1 actionable findings

Finding 1: CKV2_GHA_1

  • Severity: HIGH
  • Scanner: checkov
  • Rule ID: CKV2_GHA_1
  • Location: .github/workflows/workflow.yml:11-12

Description:
Ensure top-level permissions are not set to write-all


Report generated by Automated Security Helper (ASH) at 2026-04-24T21:46:09+00:00

@hayleypark

Copy link
Copy Markdown
Author

Continuing the effort from #72. Update documentation along with the code change

…s()` by changing direct dictionary key access (`metrics["tp"]`) to use `.get()` with a default of 0 (`metrics.get("tp", 0)`) for all six confusion matrix keys (tp, fp, tn, fn, fd, fa). This handles the edge case where `aggregate_from_comparisons([])` is called with an empty list — no documents get processed, so the confusion matrix dict remains empty, and the derived metrics calculation now gracefully returns zeros instead of crashing. All 26 tests in the file now pass.
…he "matched" count, producing FN+FA instead of FD

- __Field-level recursion:__ All Hungarian-paired items still get their fields compared (preserving existing FD behavior at the aggregate/field level for partial matches)
@github-actions

Copy link
Copy Markdown

Security Scan Results

ASH Security Scan Report

  • Report generated: 2026-04-30T23:02:57+00:00
  • Time since scan: 2 minutes

Scan Metadata

  • Project: ASH
  • Scan executed: 2026-04-30T23:00:57+00:00
  • ASH version: 3.1.2

Summary

Scanner Results

The table below shows findings by scanner, with status based on severity thresholds and dependencies:

  • Severity levels:
    • Suppressed (S): Findings that have been explicitly suppressed and don't affect scanner status
    • Critical (C): Highest severity findings that require immediate attention
    • High (H): Serious findings that should be addressed soon
    • Medium (M): Moderate risk findings
    • Low (L): Lower risk findings
    • Info (I): Informational findings with minimal risk
  • Duration (Time): Time taken by the scanner to complete its execution
  • Actionable: Number of findings at or above the threshold severity level that require attention
  • Result:
    • PASSED = No findings at or above threshold
    • FAILED = Findings at or above threshold
    • MISSING = Required dependencies not available
    • SKIPPED = Scanner explicitly disabled
    • ERROR = Scanner execution error
  • Threshold: The minimum severity level that will cause a scanner to fail
    • Thresholds: ALL, LOW, MEDIUM, HIGH, CRITICAL
    • Source: Values in parentheses indicate where the threshold is set:
      • global (global_settings section in the ASH_CONFIG used)
      • config (scanner config section in the ASH_CONFIG used)
      • scanner (default configuration in the plugin, if explicitly set)
  • Statistics calculation:
    • All statistics are calculated from the final aggregated SARIF report
    • Suppressed findings are counted separately and do not contribute to actionable findings
    • Scanner status is determined by comparing actionable findings to the threshold
Scanner Suppressed Critical High Medium Low Info Actionable Result Threshold
bandit 0 0 0 0 3526 0 0 PASSED MEDIUM (global)
cdk-nag 0 0 0 0 0 0 0 PASSED MEDIUM (global)
cfn-nag 0 0 0 0 0 0 0 MISSING MEDIUM (global)
checkov 0 1 0 0 0 0 1 SKIPPED MEDIUM (global)
detect-secrets 0 0 0 0 0 0 0 PASSED MEDIUM (global)
grype 0 0 0 0 0 0 0 MISSING MEDIUM (global)
npm-audit 0 0 0 0 0 0 0 PASSED MEDIUM (global)
opengrep 0 0 0 0 0 0 0 MISSING MEDIUM (global)
semgrep 0 0 0 0 0 0 0 PASSED MEDIUM (global)
syft 0 0 0 0 0 0 0 MISSING MEDIUM (global)

Top 1 Hotspots

Files with the highest number of security findings:

Finding Count File Location
1 .github/workflows/workflow.yml

Detailed Findings

Show 1 actionable findings

Finding 1: CKV2_GHA_1

  • Severity: HIGH
  • Scanner: checkov
  • Rule ID: CKV2_GHA_1
  • Location: .github/workflows/workflow.yml:11-12

Description:
Ensure top-level permissions are not set to write-all


Report generated by Automated Security Helper (ASH) at 2026-04-30T23:02:58+00:00

@hayleypark

Copy link
Copy Markdown
Author

3 test cases were failing with the changes + the new dev:

  • tests/structured_object_evaluator/test_bulk_structured_model_evaluator.py::TestUpdateFromComparisonResult::test_aggregate_from_comparisons_empty_list - KeyError: 'tp'
  • tests/structured_object_evaluator/test_simple_list_in_structured_list.py::test_simple_list_no_match - assert 0 == 1
    FAILED tests/structured_object_evaluator/
  • test_simple_list_in_structured_list.py::test_empty_simple_list_within_structured_list - assert (0 + 0) >= 1

Changes pushed to fix the issues:

  • commit 8d2a9c42d874ad1d3c397a69b1a242eabce35f0a updates src/stickler/structured_object_evaluator/models/metrics_helper.py to handle 'tp' count (and other metrics) for an empty list
  • commit fb94ecbad64e2d54b5b3e3464eea8466bfddfa66 updates src/stickler/structured_object_evaluator/models/structured_list_comparator.py to treat a Hungarian match with 0.0 as unmatched for object level counts and count as (FN+FA) rather than as FD

@sromoam sromoam 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.

Sorry, I thought these comments were already posted.

The behavior of aggregates should not change the behavior of the overall. I agree with the deisgn of the aggregates, but I don't agree with the changes in some of the test assertions. Let's find time to discuss.

Comment on lines +315 to +331
# Should have nested fields for aggregate metrics
assert (len(nested_fields) > 0), "Should have nested fields for aggregate metrics"

# Check that overall sections are empty (no matches above threshold)
for field_name, field_metrics in nested_fields.items():
overall_metrics = field_metrics["overall"]
aggregate_metrics = field_metrics["aggregate"]

# Overall should have some metrics from poor matches at the leaf node level.
# When the node is primitive or simple list, overall metrics will be same as aggregate metrics.
# When node is structured list, then overall can be different from aggregate due to threshold.
overall_total = sum(overall_metrics[metric] for metric in ["tp", "fa", "fd", "fp", "tn", "fn"])
assert (overall_total > 0), f"Field {field_name} overall should not be empty for poor matches"

# Aggregate should have some metrics from poor matches
aggregate_total = sum(aggregate_metrics[metric] for metric in ["tp", "fa", "fd", "fp", "tn", "fn"])
assert (aggregate_total > 0), f"Field {field_name} aggregate should have metrics from poor matches"

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.

I'm glad we're testing these counts. Could we refactor this to actually test that for primitives we get the same, but for list[StructuredModel] we get different?

Comment on lines 327 to 330
assert (
get_metric(cm["fields"]["pets"]["fields"]["petId"], "tp") == 1
), "Expected 1 true positives"
get_metric(cm["fields"]["pets"]["fields"]["petId"], "tp") == 2
), "Expected 2 true positives"
assert (

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.

This assertion should not have changed. Max the dog entity falls below the set threshold, so for the 'overall' key, it should still be 1 not 2. Becuase Max is considered a bad match, therfore no field in there is TP.

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.

This applies to the rest of the tests in this file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thank you for pointing this out. I updated relevant code and test cases not to update the overall metrics, but only update aggregate metrics.

…ly above-threshold matched pairs (+ unmatched FN/FA items) contribute; 2. "aggregate": ALL pairs contribute, regardless of threshold. Revert incorrect test changes.
@hayleypark

Copy link
Copy Markdown
Author

Latest commit 93db235 separates threshold-gating into two paths (per-field "overall" and "aggregate" to preserve "overall" behavior and reverts incorrect test changes.

@hayleypark
hayleypark requested a review from sromoam May 15, 2026 21:02
@hayleypark

Copy link
Copy Markdown
Author

Reverted changes to src/stickler/structured_object_evaluator/bulk_structured_model_evaluator.py to keep the PR focused on aggregate metrics. Will update the file in the future if needed.

@github-actions

Copy link
Copy Markdown

Security Scan Results

ASH Security Scan Report

  • Report generated: 2026-05-15T21:04:54+00:00
  • Time since scan: 2 minutes

Scan Metadata

  • Project: ASH
  • Scan executed: 2026-05-15T21:02:53+00:00
  • ASH version: 3.1.2

Summary

Scanner Results

The table below shows findings by scanner, with status based on severity thresholds and dependencies:

  • Severity levels:
    • Suppressed (S): Findings that have been explicitly suppressed and don't affect scanner status
    • Critical (C): Highest severity findings that require immediate attention
    • High (H): Serious findings that should be addressed soon
    • Medium (M): Moderate risk findings
    • Low (L): Lower risk findings
    • Info (I): Informational findings with minimal risk
  • Duration (Time): Time taken by the scanner to complete its execution
  • Actionable: Number of findings at or above the threshold severity level that require attention
  • Result:
    • PASSED = No findings at or above threshold
    • FAILED = Findings at or above threshold
    • MISSING = Required dependencies not available
    • SKIPPED = Scanner explicitly disabled
    • ERROR = Scanner execution error
  • Threshold: The minimum severity level that will cause a scanner to fail
    • Thresholds: ALL, LOW, MEDIUM, HIGH, CRITICAL
    • Source: Values in parentheses indicate where the threshold is set:
      • global (global_settings section in the ASH_CONFIG used)
      • config (scanner config section in the ASH_CONFIG used)
      • scanner (default configuration in the plugin, if explicitly set)
  • Statistics calculation:
    • All statistics are calculated from the final aggregated SARIF report
    • Suppressed findings are counted separately and do not contribute to actionable findings
    • Scanner status is determined by comparing actionable findings to the threshold
Scanner Suppressed Critical High Medium Low Info Actionable Result Threshold
bandit 0 0 0 0 3597 0 0 PASSED MEDIUM (global)
cdk-nag 0 0 0 0 0 0 0 PASSED MEDIUM (global)
cfn-nag 0 0 0 0 0 0 0 MISSING MEDIUM (global)
checkov 0 1 0 0 0 0 1 SKIPPED MEDIUM (global)
detect-secrets 0 0 0 0 0 0 0 PASSED MEDIUM (global)
grype 0 0 0 0 0 0 0 MISSING MEDIUM (global)
npm-audit 0 0 0 0 0 0 0 PASSED MEDIUM (global)
opengrep 0 0 0 0 0 0 0 MISSING MEDIUM (global)
semgrep 0 0 0 0 0 0 0 PASSED MEDIUM (global)
syft 0 0 0 0 0 0 0 MISSING MEDIUM (global)

Top 1 Hotspots

Files with the highest number of security findings:

Finding Count File Location
1 .github/workflows/workflow.yml

Detailed Findings

Show 1 actionable findings

Finding 1: CKV2_GHA_1

  • Severity: HIGH
  • Scanner: checkov
  • Rule ID: CKV2_GHA_1
  • Location: .github/workflows/workflow.yml:11-12

Description:
Ensure top-level permissions are not set to write-all


Report generated by Automated Security Helper (ASH) at 2026-05-15T21:04:55+00:00

…uctured_model_evaluator.py to keep the PR focused on aggregate metrics.
@github-actions

Copy link
Copy Markdown

Security Scan Results

ASH Security Scan Report

  • Report generated: 2026-05-15T21:47:15+00:00
  • Time since scan: 2 minutes

Scan Metadata

  • Project: ASH
  • Scan executed: 2026-05-15T21:45:10+00:00
  • ASH version: 3.1.2

Summary

Scanner Results

The table below shows findings by scanner, with status based on severity thresholds and dependencies:

  • Severity levels:
    • Suppressed (S): Findings that have been explicitly suppressed and don't affect scanner status
    • Critical (C): Highest severity findings that require immediate attention
    • High (H): Serious findings that should be addressed soon
    • Medium (M): Moderate risk findings
    • Low (L): Lower risk findings
    • Info (I): Informational findings with minimal risk
  • Duration (Time): Time taken by the scanner to complete its execution
  • Actionable: Number of findings at or above the threshold severity level that require attention
  • Result:
    • PASSED = No findings at or above threshold
    • FAILED = Findings at or above threshold
    • MISSING = Required dependencies not available
    • SKIPPED = Scanner explicitly disabled
    • ERROR = Scanner execution error
  • Threshold: The minimum severity level that will cause a scanner to fail
    • Thresholds: ALL, LOW, MEDIUM, HIGH, CRITICAL
    • Source: Values in parentheses indicate where the threshold is set:
      • global (global_settings section in the ASH_CONFIG used)
      • config (scanner config section in the ASH_CONFIG used)
      • scanner (default configuration in the plugin, if explicitly set)
  • Statistics calculation:
    • All statistics are calculated from the final aggregated SARIF report
    • Suppressed findings are counted separately and do not contribute to actionable findings
    • Scanner status is determined by comparing actionable findings to the threshold
Scanner Suppressed Critical High Medium Low Info Actionable Result Threshold
bandit 0 0 0 0 3512 0 0 PASSED MEDIUM (global)
cdk-nag 0 0 0 0 0 0 0 PASSED MEDIUM (global)
cfn-nag 0 0 0 0 0 0 0 MISSING MEDIUM (global)
checkov 0 1 0 0 0 0 1 SKIPPED MEDIUM (global)
detect-secrets 0 0 0 0 0 0 0 PASSED MEDIUM (global)
grype 0 0 0 0 0 0 0 MISSING MEDIUM (global)
npm-audit 0 0 0 0 0 0 0 PASSED MEDIUM (global)
opengrep 0 0 0 0 0 0 0 MISSING MEDIUM (global)
semgrep 0 0 0 0 0 0 0 PASSED MEDIUM (global)
syft 0 0 0 0 0 0 0 MISSING MEDIUM (global)

Top 1 Hotspots

Files with the highest number of security findings:

Finding Count File Location
1 .github/workflows/workflow.yml

Detailed Findings

Show 1 actionable findings

Finding 1: CKV2_GHA_1

  • Severity: HIGH
  • Scanner: checkov
  • Rule ID: CKV2_GHA_1
  • Location: .github/workflows/workflow.yml:11-12

Description:
Ensure top-level permissions are not set to write-all


Report generated by Automated Security Helper (ASH) at 2026-05-15T21:47:16+00:00

@github-actions

github-actions Bot commented May 22, 2026

Copy link
Copy Markdown

Security Scan Results

ASH Security Scan Report

  • Report generated: 2026-07-16T22:12:17+00:00
  • Time since scan: 1 minute

Scan Metadata

  • Project: ASH
  • Scan executed: 2026-07-16T22:10:47+00:00
  • ASH version: 3.1.2

Summary

Scanner Results

The table below shows findings by scanner, with status based on severity thresholds and dependencies:

  • Severity levels:
    • Suppressed (S): Findings that have been explicitly suppressed and don't affect scanner status
    • Critical (C): Highest severity findings that require immediate attention
    • High (H): Serious findings that should be addressed soon
    • Medium (M): Moderate risk findings
    • Low (L): Lower risk findings
    • Info (I): Informational findings with minimal risk
  • Duration (Time): Time taken by the scanner to complete its execution
  • Actionable: Number of findings at or above the threshold severity level that require attention
  • Result:
    • PASSED = No findings at or above threshold
    • FAILED = Findings at or above threshold
    • MISSING = Required dependencies not available
    • SKIPPED = Scanner explicitly disabled
    • ERROR = Scanner execution error
  • Threshold: The minimum severity level that will cause a scanner to fail
    • Thresholds: ALL, LOW, MEDIUM, HIGH, CRITICAL
    • Source: Values in parentheses indicate where the threshold is set:
      • global (global_settings section in the ASH_CONFIG used)
      • config (scanner config section in the ASH_CONFIG used)
      • scanner (default configuration in the plugin, if explicitly set)
  • Statistics calculation:
    • All statistics are calculated from the final aggregated SARIF report
    • Suppressed findings are counted separately and do not contribute to actionable findings
    • Scanner status is determined by comparing actionable findings to the threshold
Scanner Suppressed Critical High Medium Low Info Actionable Result Threshold
bandit 0 0 0 0 4508 0 0 PASSED MEDIUM (global)
cdk-nag 0 0 0 0 0 0 0 PASSED MEDIUM (global)
cfn-nag 0 0 0 0 0 0 0 MISSING MEDIUM (global)
checkov 0 0 0 0 0 0 0 PASSED MEDIUM (global)
detect-secrets 0 0 0 0 0 0 0 PASSED MEDIUM (global)
grype 0 0 0 0 0 0 0 MISSING MEDIUM (global)
npm-audit 0 0 0 0 0 0 0 PASSED MEDIUM (global)
opengrep 0 0 0 0 0 0 0 MISSING MEDIUM (global)
semgrep 0 19 0 0 0 0 19 FAILED MEDIUM (global)
syft 0 0 0 0 0 0 0 MISSING MEDIUM (global)

Top 7 Hotspots

Files with the highest number of security findings:

Finding Count File Location
7 .github/workflows/workflow.yml
3 .github/workflows/security.yaml
2 .github/workflows/docs.yml
2 .github/workflows/lint.yaml
2 .github/workflows/run_pytest.yaml
2 .github/workflows/security-pr-comment.yaml
1 pyproject.toml

Detailed Findings

Show 19 actionable findings

Finding 1: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/docs.yml:19

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 2: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/docs.yml:22

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 3: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/lint.yaml:16

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 4: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/lint.yaml:18

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 5: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/run_pytest.yaml:16

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 6: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/run_pytest.yaml:18

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 7: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security-pr-comment.yaml:20

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/download-artifact@v4

Finding 8: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security-pr-comment.yaml:27

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/github-script@v7

Finding 9: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security.yaml:18

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 10: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security.yaml:20

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 11: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/security.yaml:42

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/upload-artifact@v4

Finding 12: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:18

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

- uses: actions/checkout@v4

Finding 13: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:22

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: astral-sh/setup-uv@v5

Finding 14: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:28

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/upload-artifact@v4

Finding 15: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:45

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/download-artifact@v4

Finding 16: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:50

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: pypa/gh-action-pypi-publish@release/v1

Finding 17: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:64

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: actions/download-artifact@v4

Finding 18: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag
  • Location: .github/workflows/workflow.yml:69

Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

Code Snippet:

uses: pypa/gh-action-pypi-publish@release/v1

Finding 19: package_managers.uv.uv-missing-dependency-cooldown.uv-missing-dependency-cooldown

  • Severity: HIGH
  • Scanner: semgrep
  • Rule ID: package_managers.uv.uv-missing-dependency-cooldown.uv-missing-dependency-cooldown
  • Location: pyproject.toml:116-117

Description:
This pyproject.toml configures uv but does not set a dependency cooldown. Newly published packages can be malicious or unstable. Add exclude-newer = "7 days" under [tool.uv] to wait 7 days before resolving newly published package versions. Added in: 0.9.17 Reference: https://docs.astral.sh/uv/concepts/resolution/#dependency-cooldowns

Code Snippet:

[tool.uv]
default-groups = ["dev"]

Report generated by Automated Security Helper (ASH) at 2026-07-16T22:12:17+00:00

…sons export

The dev merge overwrote the v0.4.0 bulk evaluator with an older version
missing weighted_overall_score, persistent JSONL handle, accumulator
error isolation, and update_from_comparison_result(). Restore from main
and add missing imports (LevenshteinComparator, NumericComparator, math)
to the test file.
sromoam added a commit that referenced this pull request Jun 2, 2026
…aggregate rollup

Implements the missing piece from PR #115: BulkStructuredModelEvaluator
now rolls up per-document `cm['aggregate']` blocks into corpus-level
totals via a plug-in accumulator, mirroring the existing
ConfidenceAccumulator pattern.

The accumulator is added to the default accumulators list, so
corpus-level aggregate metrics are on by default. Users access them
via ProcessEvaluation.accumulator_metrics["aggregate_metrics"] with the
familiar {overall, fields} shape — overall holds summed tp/fd/fa/fn/tn
counts plus derived precision/recall/f1/accuracy, fields keys
flat dotted paths (e.g. "items.sku") with the same shape.

Implementation
- New module: src/stickler/structured_object_evaluator/models/aggregate/
  with __init__.py and accumulator.py defining
  AggregateConfusionMatrixAccumulator(PostComparisonAccumulator).
- accumulate() reads comparison_result["confusion_matrix"], skips
  silently if absent (i.e. include_confusion_matrix=False), then sums
  the top-level "aggregate" block plus walks fields/nested_fields
  recursively to flatten dotted paths.
- compute() returns None if no documents contributed; otherwise emits
  the {overall, fields} shape with derived metrics computed at each
  level via MetricsHelper.calculate_derived_metrics.
- get_state/load_state/merge_state implemented for distributed eval
  and checkpointing; merge_state is additive on overall + per-field
  counts.
- Wired into BulkStructuredModelEvaluator default accumulators list at
  bulk_structured_model_evaluator.py:134-137 alongside
  ConfidenceAccumulator.

Tests (+21 net new)
- tests/structured_object_evaluator/test_aggregate_accumulator.py
  (14 unit tests): ABC conformance, reset, empty compute, single/multi
  document accumulation, dotted-path flattening at 2 and 3 levels,
  derived metrics presence, missing confusion_matrix and aggregate
  keys handled gracefully, state round-trip via JSON, merge_state
  additivity.
- tests/structured_object_evaluator/test_bulk_aggregate_integration.py
  (7 integration tests): default-includes-aggregate, parity test
  (manually summed per-doc aggregate == bulk rollup), threshold-gated
  FD pair recursion at corpus scale, distributed-eval merge_state
  round-trip, opt-out via custom accumulators list, empty-evaluation
  no-crash.
- test_confidence_metrics.py: one assertion updated to match the new
  default accumulators list (was len==1, now ordered name list of 2).

Documentation
- aggregate-metrics.md: flipped the "Bulk evaluator does not yet
  aggregate" pitfall into a positive "Corpus-level aggregate metrics
  (bulk evaluation)" section with a code snippet showing
  ProcessEvaluation.accumulator_metrics["aggregate_metrics"] access.
- aggregate-metrics.md: added a "Bulk evaluation" subsection under
  "Node Types and Aggregation Behavior" introducing the plug-in
  accumulator pattern and naming both built-ins.
- aggregate-metrics.md: new Example 3 "Corpus-level Rollup with
  BulkStructuredModelEvaluator" walking through 3 documents (TP-only,
  FD pair, unmatched FN) and showing the corpus-level rollup output.
- examples/notebooks/Aggregate_metrics.ipynb: new Example 3 section
  using the same fixture as the doc example, with corpus rollup and
  per-field flat-path output. Also includes a section on opting out
  or extending the default accumulator set. Notebook executes cleanly
  end-to-end and reproduces the doc's published numbers exactly.

Test suite: 1121 passed / 2 skipped (was 1100; +21 net new tests; 0
regressions).
sromoam added 3 commits June 8, 2026 14:52
Fixes documentation accuracy and one regression caught during review.
Adds boundary-case tests that pin invariants the docs claim but were
not exercised before. All test deltas reflect actual code behavior;
no behavior loosening.

Documentation
- aggregate-metrics.md: added a "Why aggregates?" motivation section
  that contrasts overall (object-level, threshold-gated) with
  aggregate (field-level rollup across all items) so readers see the
  problem the feature solves before diving into mechanics.
- aggregate-metrics.md Example 2: per-field `overall` JSON corrected
  to {tp:1, fd:0, fn:1} for sku/description/qty (the BBB FD pair is
  threshold-gated out of `overall` but still appears in `aggregate`).
  Added prose contrasting `overall` vs `aggregate` so the example is
  coherent end-to-end.
- aggregate-metrics.md: appended a "Common pitfalls" section with
  five linkable subsections (overall vs aggregate divergence,
  zero-similarity pair handling, empty-list semantics, bulk evaluator
  not yet rolling up aggregate, recall_with_fd flag).
- threshold-gated-evaluation.md "Result Structure" worked example:
  reframed from 3 GT vs 3 Pred (which Hungarian fully pairs, making
  the documented {tp:1, fd:1, fn:1, fa:1} unreachable) to 3 GT vs
  2 Pred. Updated counts and derived metrics to match what the code
  actually emits.
- structured_list_comparator.py module docstring: replaced the
  outdated "Current Behavior Preserved (including bugs)" block with
  an accurate description of post-PR behavior.

Code
- hungarian.py: restored the `if score > 0:` gate in
  HungarianMatcher.calculate_metrics' single-item-list shortcut so
  zero-similarity pairs decompose as FN+FA, matching dev/main and
  consistent with the >0.0 filter in StructuredListComparator.

Tests
- test_threshold_gated_recursion.py adds
  test_zero_similarity_pair_is_unmatched_not_fd, pinning the
  `> 0.0` boundary at structured_list_comparator.py:209. Uses a 2x2
  non-overlapping container so Hungarian's pair-assignment branch is
  actually exercised; mutation-tested by flipping > to >= (test fails).
- test_aggregate_cases.py adds
  test_primitive_list_zero_similarity_treated_as_unmatched as a
  regression test for the hungarian.py single-item fix.
- test_aggregate_cases.py: 7 of 12 TestAggregation tests now also
  assert derived metrics (cm_precision, cm_recall, cm_f1, cm_accuracy)
  under agg_results['derived'], pinning the docs claim that derived
  metrics are recomputed at each aggregate level.
- test_ecommerce_orders_aggregate_comprehensive.py
  test_threshold_based_classification: replaced two tautology
  assertions (tp > 0; sum > 0) with concrete per-child overall and
  aggregate counts that pin threshold behavior at every level.
- test_ecommerce_orders_aggregate_comprehensive.py: comment claiming
  "5 TP, 1 TN" updated to match the asserted tp=4/tn=2; removed an
  orphan __main__ call to a non-existent test method.

Test suite: 1100 passed / 2 skipped (was 1098 / 2, +2 from new tests).
No regressions; mutation-guard verified on the new boundary test.
…aggregate rollup

Implements the missing piece from PR #115: BulkStructuredModelEvaluator
now rolls up per-document `cm['aggregate']` blocks into corpus-level
totals via a plug-in accumulator, mirroring the existing
ConfidenceAccumulator pattern.

The accumulator is added to the default accumulators list, so
corpus-level aggregate metrics are on by default. Users access them
via ProcessEvaluation.accumulator_metrics["aggregate_metrics"] with the
familiar {overall, fields} shape — overall holds summed tp/fd/fa/fn/tn
counts plus derived precision/recall/f1/accuracy, fields keys
flat dotted paths (e.g. "items.sku") with the same shape.

Implementation
- New module: src/stickler/structured_object_evaluator/models/aggregate/
  with __init__.py and accumulator.py defining
  AggregateConfusionMatrixAccumulator(PostComparisonAccumulator).
- accumulate() reads comparison_result["confusion_matrix"], skips
  silently if absent (i.e. include_confusion_matrix=False), then sums
  the top-level "aggregate" block plus walks fields/nested_fields
  recursively to flatten dotted paths.
- compute() returns None if no documents contributed; otherwise emits
  the {overall, fields} shape with derived metrics computed at each
  level via MetricsHelper.calculate_derived_metrics.
- get_state/load_state/merge_state implemented for distributed eval
  and checkpointing; merge_state is additive on overall + per-field
  counts.
- Wired into BulkStructuredModelEvaluator default accumulators list at
  bulk_structured_model_evaluator.py:134-137 alongside
  ConfidenceAccumulator.

Tests (+21 net new)
- tests/structured_object_evaluator/test_aggregate_accumulator.py
  (14 unit tests): ABC conformance, reset, empty compute, single/multi
  document accumulation, dotted-path flattening at 2 and 3 levels,
  derived metrics presence, missing confusion_matrix and aggregate
  keys handled gracefully, state round-trip via JSON, merge_state
  additivity.
- tests/structured_object_evaluator/test_bulk_aggregate_integration.py
  (7 integration tests): default-includes-aggregate, parity test
  (manually summed per-doc aggregate == bulk rollup), threshold-gated
  FD pair recursion at corpus scale, distributed-eval merge_state
  round-trip, opt-out via custom accumulators list, empty-evaluation
  no-crash.
- test_confidence_metrics.py: one assertion updated to match the new
  default accumulators list (was len==1, now ordered name list of 2).

Documentation
- aggregate-metrics.md: flipped the "Bulk evaluator does not yet
  aggregate" pitfall into a positive "Corpus-level aggregate metrics
  (bulk evaluation)" section with a code snippet showing
  ProcessEvaluation.accumulator_metrics["aggregate_metrics"] access.
- aggregate-metrics.md: added a "Bulk evaluation" subsection under
  "Node Types and Aggregation Behavior" introducing the plug-in
  accumulator pattern and naming both built-ins.
- aggregate-metrics.md: new Example 3 "Corpus-level Rollup with
  BulkStructuredModelEvaluator" walking through 3 documents (TP-only,
  FD pair, unmatched FN) and showing the corpus-level rollup output.
- examples/notebooks/Aggregate_metrics.ipynb: new Example 3 section
  using the same fixture as the doc example, with corpus rollup and
  per-field flat-path output. Also includes a section on opting out
  or extending the default accumulator set. Notebook executes cleanly
  end-to-end and reproduces the doc's published numbers exactly.

Test suite: 1121 passed / 2 skipped (was 1100; +21 net new tests; 0
regressions).
… + close test gaps

Three follow-ups identified during PR review of the bulk-aggregate work:

1. recall_with_fd parameter (real fix). The accumulator was hardcoding
   recall_with_fd=False when computing derived metrics, so corpus-level
   users had no way to opt into the include-FD recall formula that
   StructuredModel.compare_with already supports per-document. Adds an
   optional recall_with_fd kwarg on AggregateConfusionMatrixAccumulator;
   default remains False (matches per-document default), opt-in flips
   the formula at every aggregate level (overall + per-field). Raw
   counts are unchanged; only the derived block is affected.

2. Bulk evaluator state-lifecycle test gap. The accumulator's own
   get_state/load_state/merge_state were unit-tested directly, but no
   test exercised the path through BulkStructuredModelEvaluator.get_state
   / .load_state which is what distributed-eval consumers actually call.
   Adds two integration tests:
   - bulk get_state -> fresh evaluator load_state produces identical
     aggregate_metrics
   - bulk load_state followed by additional .update() calls additively
     produces the same aggregate as a single-pass run

3. pretty_print_metrics gap. BulkStructuredModelEvaluator.pretty_print_metrics
   was walking process_eval.metrics and process_eval.field_metrics but
   ignoring accumulator_metrics, so the aggregate rollup was invisible
   in the human-readable summary. Adds a new "AGGREGATE METRICS" section
   to the printer surfacing corpus-level overall counts, derived metrics,
   and per-field-path lines (sorted by F1 desc, mirroring the existing
   FIELD-LEVEL METRICS layout). Confidence is also accumulator-only
   today and equally invisible — left as a separate cleanup.

Tests added (+7)
- test_aggregate_accumulator.py::TestRecallWithFdParameter (4 tests):
  default uses textbook recall, opt-in uses include-FD recall, the flag
  propagates to per-field derived blocks, raw counts identical between
  flavors.
- test_bulk_aggregate_integration.py::TestBulkEvaluatorStateLifecycle
  (2 tests): get/load round-trip, load+continue equals single-pass.
- test_bulk_aggregate_integration.py::TestPrettyPrintSurfacesAggregate
  (1 test): captures stdout, asserts AGGREGATE METRICS heading present.

Suite: 1128 passed / 2 skipped (was 1121, +7 net new tests).

@adiadd adiadd 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.

thanks for sticking with this one, the feature is the right direction but a few things worth resolving before merge, roughly in priority order:

blocking:

  • per-field overall still changes vs dev in three nested scenarios (inner-list ungating, unmatched-item seeding, empty-vs-empty leaf seeding), which is the exact condition behind the standing changes-requested. see the comment on structured_list_comparator.py:316 for the verified side-by-side deltas.

worth resolving:

  • zero-similarity pairs now report FN+FA at object level but FD-shaped counts in per-field aggregate, and vanish from per-field overall entirely, see the comment at structured_list_comparator.py:209. the doc-level metric change from that reclassification also still needs explicit sign-off on the thread.

  • TestWeightedOverallScoreParity was deleted but still passes at head, worth restoring as-is (comment on test_bulk_evaluator_parity.py).

  • class-level _nesting_depth is mutable global state (comment at structured_list_comparator.py:49), and ~290 lines of dead code from the rewrite are still in the file (comment at :460).

  • the bulk evaluator default accumulator change is back after the thread said it was reverted, and the PR description is stale about include_aggregates / recall_with_fd (comment on bulk_structured_model_evaluator.py:136).

  • pretty-print labeling and rollup granularity (comment at bulk_structured_model_evaluator.py:809).

appreciate it!

Comment thread src/stickler/structured_object_evaluator/models/structured_list_comparator.py Outdated
Comment thread tests/structured_object_evaluator/test_bulk_evaluator_parity.py
Comment thread src/stickler/structured_object_evaluator/models/structured_list_comparator.py Outdated
Comment thread src/stickler/structured_object_evaluator/models/structured_list_comparator.py Outdated
Comment thread src/stickler/structured_object_evaluator/bulk_structured_model_evaluator.py Outdated
Comment thread tests/structured_object_evaluator/test_correct_classification_definitions.py Outdated
Comment thread src/stickler/structured_object_evaluator/models/aggregate/accumulator.py Outdated
Blocking:
- Remove _nesting_depth class state; gate per-field 'overall' at every
  nesting level (matching dev behavior and docs). This resolves the three
  per-field overall deltas (inner-list ungating, unmatched-item seeding,
  empty-vs-empty leaf seeding).

Worth resolving:
- Keep zero-similarity FN+FA reclassification (semantic correctness) and
  propagate aggregate keys through _recursive_merge_fields so inner list
  comparisons correctly surface in outer aggregate pre-seeding.
- Restore TestWeightedOverallScoreParity (passes at head, deletion was
  unnecessary).
- Remove ~290 lines of dead code: _handle_hierarchical_field,
  _handle_non_hierarchical_field, _add_derived_metrics_recursively,
  _should_use_hierarchical_structure, _get_all_leaves,
  _get_leaves_under_field, _get_nested_model_type, and stale references
  in comparison_dispatcher.py.
- Fix pretty-print label: 'ungated by match_threshold' -> 'includes
  below-threshold structured list pairs'. Show TN-only fields. Add
  hierarchy note in comment.

Nits:
- Fix misleading test comment (describes aggregate, not overall).
- Remove non-existent test_nested_category_description_aggregation from
  __main__ block.
- Remove stale 'per the design spec' docstring reference; add comment
  about docs_with_data fallback behavior.

Test assertion updates reflect the new correct behavior:
- Empty-vs-empty lists emit single list-level TN=1 (no per-leaf
  expansion, matching dev).
- Inner list aggregate keys are properly propagated through merging.
… name

Rename references from _should_use_hierarchical_structure() to
_is_structured_list_field() in the architecture documentation to
reflect the current codebase method naming.
@hayleypark

Copy link
Copy Markdown
Author

Thanks @adiadd for the thorough review. All items addressed in commit 0e51087:

Blocking: resolved:

  • Per-field overall now matches dev exactly. Removed _nesting_depth; threshold gating applies at every nesting level unconditionally. All three deltas you identified (inner-list ungating, unmatched-item seeding, empty-vs-empty leaf seeding) are gone. Verified side-by-side.

Worth resolving, all resolved:

  • Zero-similarity consistency: matched_gt_indices/matched_pred_indices now exclude zero-sim pairs, and the matched-pairs loop skips them explicitly. Object level, per-field overall, and per-field aggregate all report FN+FA consistently. Re: the FD→FN+FA reclassification itself changing doc-level recall — this is intentional (zero-similarity pairing is meaningless), keeping it as-is.
  • TestWeightedOverallScoreParity: Restored verbatim. Passes at head.
  • _nesting_depth + dead code: _nesting_depth removed entirely (no class-level mutable state). Deleted ~290 lines of dead code (_handle_hierarchical_field, _handle_non_hierarchical_field, _add_derived_metrics_recursively, _should_use_hierarchical_structure, leaf-seeding helpers). Stale references in comparison_dispatcher.py and architecture.md cleaned up.
  • Bulk evaluator defaults: AggregateConfusionMatrixAccumulator stays in defaults (added by @sromoam in his commits). PR description updated — removed stale include_aggregates reference, clarified recall_with_fd is on the accumulator constructor.
  • Pretty-print: Label changed to "includes below-threshold structured list pairs". Per-field loop now filters to leaf-only paths (no visual doubling). TN-only fields are shown.

All nits (misleading comment, non-existent main method, stale docstring reference) also fixed.

@adiadd adiadd 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.

appreciate all the changes and fixes hayley! a couple findings still which i've outlined within some more comments

also, the branch currently conflicts with dev on uv.lock only (everything else auto-merges), so a rebase/regen should clear it, and the zero-sim FD to FN+FA reclassification could still use an explicit sign-off from @sromoam on the thread since it changes doc-level overall counts.

almost there!

field_info and model_class._is_structured_field_type(field_info)
)
#take all the tp values and convert to fn
field_details_tmp["fields"] = self._switch_metrics(field_details_tmp["fields"] , source_metric='tp', target_metric='fn')

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.

blocking: so the unmatched-item seeding delta from my last review is still here, and it now cuts both ways: the unmatched item's nested-list FN/FA land in per-field overall (which dev keeps at zero) while going missing from aggregate (where #94 actually wants them).

reproducer: Top.mids -> Mid.leaves -> Leaf.name, GT [Mid("alpha", leaves=[l1, l2]), Mid("beta", leaves=[l3])] vs pred [Mid("beta", leaves=[l3])]. dev gives mids.fields.leaves.overall.fn=0 with no leaf entries; head gives leaves.fields.name overall {tp:1, fn:2} but aggregate {tp:1, fn:0}, and the root aggregate reports fn=1 instead of fn=3. symmetric on the pred side (fa/fp dropped). it's also input-dependent: with no matched pairs at all the leaf has no pre-seeded aggregate, the overall fallback fires, and the fn does get counted, so the same missing item counts or not depending on unrelated siblings.

mechanism, aggregate side: _switch_metrics builds a fresh target dict that only carries overall and fields keys (:371-397), so the aggregate keys the self-compare pre-seeded at inner leaves get dropped, then _preseed_aggregate_from_full prefers full["aggregate"] over full["overall"] at leaves (:428-431), and that aggregate was summed from matched pairs only. overall side: the gated_results.append for unmatched items at :345/:358 feeds the whole switched subtree (nested lists included) into the gated view, where dev only seeded the unmatched item's primitive fields.

suggested fix: have _switch_metrics also convert and propagate aggregate keys (tp to fn/fa/fp inside them, same as it does for overall) so unmatched contributions survive pre-seeding, and keep the nested-list part of the switched subtree out of gated_results so overall stays dev-shaped (the primitive seeding matches dev and can stay). this is exactly the visibility gap #94 was filed for, and the corpus accumulator inherits the inflated recall either way.

"weight": weight,
}

case _:

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.

two things on this branch: the comment says "Both non-empty" but case _ also matches (0, n) and (n, 0), and those fall through to hungarian matching on an empty list (which works, fa/fn come out right, hungarian_helper returns empty pairs without calling hungarian). worth the comment saying so.

the bigger note: for empty-vs-populated, this path now emits per-leaf overall rows that dev never emitted (e.g. GT [] vs 2 pred items yields items.fields.sku.overall fa=2/fp=2, dev just had the list-level counts). object and list level match dev exactly, and the seeding is consistent with how dev handles unmatched items in nonempty lists, so i'd say it's a defensible consistency fix, but it is a real per-field overall delta vs dev and it isn't in the PR description. given the standing concern about overall changes, worth calling it out explicitly on the thread so @sromoam can sign off rather than discover it later.

related shape note: below-threshold inner lists now emit all-zero leaf overall shells where dev omitted the keys entirely (dev: parts.fields = {}, head: parts.fields.name.overall all zeros). counts agree, but anything iterating fields keys or asserting exact dict shapes sees new entries, worth a line in the release notes.

if gt_val and isinstance(gt_val[0], StructuredModel):
# CASE 2: Handle list fields.
# Note, if it's a primitive list, null/empty cases already handled in STEP 3.
elif is_list_field:

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.

low-probability thing: this now routes on the static annotation where dev also required isinstance(gt_val, list) and isinstance(pred_val, list) before delegating. a non-list runtime value on a list-annotated field (reachable via model_construct or direct assignment, normal validation blocks it) now reaches compare_struct_list_with_scores and crashes in hungarian matching with AttributeError: 'str' object has no attribute 'compare_recursive' (verified with a str on either side at head), where dev classified the same input as FD. a cheap isinstance guard before this delegation would restore the graceful failure mode.




class TestWeightedOverallScoreParity:

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.

minor: the restore landed as a duplicate, TestWeightedOverallScoreParity is now defined twice in this file (here and at :965, which is the copy that came in via the dev merge). the bodies are identical so pytest just collects the second definition and nothing is lost today, but the first copy is dead code and it'll bite when someone edits one and not the other. worth dropping one.


print("Running comprehensive aggregate tests...")

try:

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.

nit on the __main__ block: the try/except prints the failure and exits 0, so running the file directly looks green even when a test fails. since pytest is the real runner i'd drop the block entirely. also two stale comments upstream contradict their assertions: :263 says "5 true positives... 1 true negative" vs the asserted tp=4/tn=2 at :264-267, and :684-685 says "aggregate TN=2 (one per leaf...)" vs the asserted TN=1 at :710.

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.

5 participants