sinonym 0.2.8__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {sinonym-0.2.8 → sinonym-0.3.0}/PKG-INFO +27 -4
  2. {sinonym-0.2.8 → sinonym-0.3.0}/README.md +25 -2
  3. {sinonym-0.2.8 → sinonym-0.3.0}/pyproject.toml +33 -3
  4. {sinonym-0.2.8 → sinonym-0.3.0}/scripts/README.md +11 -1
  5. {sinonym-0.2.8 → sinonym-0.3.0}/scripts/check_test_status.py +80 -111
  6. sinonym-0.3.0/scripts/generate_name_statistics.py +253 -0
  7. sinonym-0.3.0/scripts/generate_surname_romanizations.py +282 -0
  8. sinonym-0.3.0/scripts/train_ml_classifier_for_chinese_vs_japanese.py +155 -0
  9. sinonym-0.3.0/scripts/verify_multiprocess.py +379 -0
  10. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/chinese_names_data.py +259 -0
  11. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/coretypes/results.py +16 -6
  12. sinonym-0.3.0/sinonym/data/README.md +74 -0
  13. sinonym-0.3.0/sinonym/data/given_position.csv +354 -0
  14. sinonym-0.3.0/sinonym/data/name_order_routing/README.md +210 -0
  15. sinonym-0.3.0/sinonym/data/name_order_routing/pp_abstain_labels.jsonl +750 -0
  16. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/data/name_order_routing/pp_vys_abstain_labels.jsonl +991 -991
  17. sinonym-0.3.0/sinonym/data/surname_romanizations.csv +254 -0
  18. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/detector.py +254 -78
  19. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/pipeline/name_order_routing.py +250 -461
  20. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/__init__.py +2 -0
  21. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/batch_analysis.py +142 -123
  22. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/ethnicity.py +57 -186
  23. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/initialization.py +219 -16
  24. sinonym-0.3.0/sinonym/services/name_lookup.py +151 -0
  25. sinonym-0.3.0/sinonym/services/non_person.py +234 -0
  26. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/normalization.py +4 -0
  27. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/order_metadata.py +2 -1
  28. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/parsing.py +187 -168
  29. sinonym-0.3.0/sinonym/services/process_pool.py +294 -0
  30. sinonym-0.3.0/sinonym/timo/config.yaml +22 -0
  31. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/timo/integration_test.py +56 -1
  32. sinonym-0.3.0/sinonym/timo/interface.py +756 -0
  33. {sinonym-0.2.8 → sinonym-0.3.0}/tests/conftest.py +24 -12
  34. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_basic_chinese_names.py +0 -1
  35. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_batch.py +70 -2
  36. sinonym-0.3.0/tests/test_bug_report_fixes.py +541 -0
  37. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_check_test_status.py +62 -3
  38. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_misc.py +0 -2
  39. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_mixed_production_cases.py +0 -1
  40. sinonym-0.3.0/tests/test_multiprocess_pool.py +367 -0
  41. sinonym-0.3.0/tests/test_name_order_routing_label_fixtures.py +133 -0
  42. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_name_order_routing_rules.py +312 -85
  43. sinonym-0.3.0/tests/test_name_statistics_data.py +34 -0
  44. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_performance.py +2 -2
  45. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_regression_proposals.py +91 -30
  46. sinonym-0.3.0/tests/test_surname_romanizations.py +316 -0
  47. sinonym-0.3.0/tests/test_timo_interface.py +635 -0
  48. sinonym-0.2.8/sinonym/data/README.md +0 -51
  49. sinonym-0.2.8/sinonym/data/name_order_routing/README.md +0 -29
  50. sinonym-0.2.8/sinonym/data/name_order_routing/pp_abstain_labels.jsonl +0 -750
  51. sinonym-0.2.8/sinonym/services/non_person.py +0 -134
  52. sinonym-0.2.8/sinonym/services/process_pool.py +0 -183
  53. sinonym-0.2.8/sinonym/timo/config.yaml +0 -12
  54. sinonym-0.2.8/sinonym/timo/interface.py +0 -347
  55. sinonym-0.2.8/tests/test_multiprocess_pool.py +0 -85
  56. sinonym-0.2.8/tests/test_name_order_routing_label_fixtures.py +0 -74
  57. sinonym-0.2.8/tests/test_timo_interface.py +0 -223
  58. {sinonym-0.2.8 → sinonym-0.3.0}/.gitignore +0 -0
  59. {sinonym-0.2.8 → sinonym-0.3.0}/LICENSE +0 -0
  60. {sinonym-0.2.8 → sinonym-0.3.0}/scripts/name_order_routing_rules.py +0 -0
  61. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/__init__.py +0 -0
  62. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/coretypes/__init__.py +0 -0
  63. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/coretypes/config.py +0 -0
  64. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/data/acl_2025_authors.txt +0 -0
  65. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/data/chinese_japanese_classifier.joblib +0 -0
  66. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/data/chinese_japanese_classifier.skops +0 -0
  67. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/data/familyname_orcid.csv +0 -0
  68. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/data/givenname_orcid.csv +0 -0
  69. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/data/model_features.json +0 -0
  70. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/ml_model_components.py +0 -0
  71. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/patterns/__init__.py +0 -0
  72. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/patterns/compiled_patterns.py +0 -0
  73. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/patterns/regex_builders.py +0 -0
  74. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/pipeline/__init__.py +0 -0
  75. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/resources.py +0 -0
  76. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/cache.py +0 -0
  77. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/services/formatting.py +0 -0
  78. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/text_processing/__init__.py +0 -0
  79. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/text_processing/compound_detector.py +0 -0
  80. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/text_processing/text_normalizer.py +0 -0
  81. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/text_processing/text_preprocessor.py +0 -0
  82. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/timo/__init__.py +0 -0
  83. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/utils/__init__.py +0 -0
  84. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/utils/string_manipulation.py +0 -0
  85. {sinonym-0.2.8 → sinonym-0.3.0}/sinonym/utils/thread_cache.py +0 -0
  86. {sinonym-0.2.8 → sinonym-0.3.0}/tests/_case_assertions.py +0 -0
  87. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_acl.py +0 -0
  88. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_all_chinese_inputs.py +0 -0
  89. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_compound_names.py +0 -0
  90. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_compound_surname_formats.py +0 -0
  91. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_middle_names.py +0 -0
  92. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_mixed_scripts.py +0 -0
  93. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_ml_japanese_detection.py +0 -0
  94. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_name_formatting.py +0 -0
  95. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_non_chinese_rejection.py +0 -0
  96. {sinonym-0.2.8 → sinonym-0.3.0}/tests/test_regional_variants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sinonym
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: Chinese Name Detection and Normalization Module
5
5
  Project-URL: Homepage, https://github.com/allenai/sinonym
6
6
  Project-URL: Repository, https://github.com/allenai/sinonym
@@ -24,7 +24,7 @@ Requires-Dist: numpy>=1.24
24
24
  Requires-Dist: pydantic<2,>=1.10
25
25
  Requires-Dist: pypinyin>=0.44.0
26
26
  Requires-Dist: requests>=2.31
27
- Requires-Dist: scikit-learn<2,>=1.2
27
+ Requires-Dist: scikit-learn==1.7.1
28
28
  Requires-Dist: scipy>=1.10
29
29
  Requires-Dist: skops>=0.9
30
30
  Provides-Extra: dev
@@ -411,15 +411,25 @@ def main():
411
411
  detector = ChineseNameDetector()
412
412
  names_a = ["Li Wei", "Wang Weiming", "Zhang Ming"]
413
413
  names_b = ["Xin Liu", "Yang Li", "Chen Huang"]
414
+ author_batches = [
415
+ ["Wang An", "Yan Li", "Wu Gang", "Li Bao"],
416
+ ["Li Wei", "Wang Weiming", "Zhang Ming"],
417
+ ]
418
+
419
+ # High-level auto wrappers choose local vs process-pool execution.
420
+ # Each inner author list remains an independent batch-context boundary.
421
+ normalized = detector.normalize_names(names_a, parallel="auto")
422
+ batch_results = detector.process_name_batches(author_batches, parallel="auto")
414
423
 
415
424
  # Reuse workers across many calls
416
425
  with detector.create_persistent_multiprocess_pool(max_workers=6, chunk_size=64) as pool:
417
426
  results_a = pool.normalize_names(names_a)
418
427
  results_b = pool.normalize_names(names_b)
428
+ persistent_batch_results = pool.process_name_batches(author_batches)
419
429
 
420
- # One-off compatibility wrapper with full batch-context semantics
430
+ # One-off temporary pool with process_name_batch batch-context semantics
421
431
  single_batch = detector.process_name_batch_multiprocess(names_a, max_workers=6, chunk_size=64)
422
- return results_a, results_b, single_batch
432
+ return normalized, batch_results, results_a, results_b, persistent_batch_results, single_batch
423
433
 
424
434
  if __name__ == "__main__":
425
435
  main()
@@ -428,6 +438,19 @@ if __name__ == "__main__":
428
438
  Use the `if __name__ == "__main__":` guard in scripts to ensure safe process
429
439
  spawning on Windows and macOS.
430
440
 
441
+ `normalize_names()` has per-name `normalize_name()` semantics. `process_name_batches()`
442
+ is the high-level wrapper for many independent author lists: each inner list
443
+ gets normal batch-format correction, and `parallel="auto"` uses conservative
444
+ platform-aware thresholds to avoid one-off process-startup regressions.
445
+
446
+ For repeated high-throughput calls, keep a persistent pool open. The persistent
447
+ pool exposes `normalize_names()` for independent names and `process_name_batches()`
448
+ for independent author-list batches. The one-off
449
+ `process_name_batch_multiprocess()` method is kept for compatibility and has
450
+ `process_name_batch()` batch-context semantics. For per-name multiprocessing,
451
+ use `normalize_names(..., parallel="always")` or a persistent pool's
452
+ `normalize_names()` method.
453
+
431
454
  ### When to Use Batch Processing
432
455
 
433
456
  * **Academic Papers**: Author lists typically follow consistent formatting
@@ -373,15 +373,25 @@ def main():
373
373
  detector = ChineseNameDetector()
374
374
  names_a = ["Li Wei", "Wang Weiming", "Zhang Ming"]
375
375
  names_b = ["Xin Liu", "Yang Li", "Chen Huang"]
376
+ author_batches = [
377
+ ["Wang An", "Yan Li", "Wu Gang", "Li Bao"],
378
+ ["Li Wei", "Wang Weiming", "Zhang Ming"],
379
+ ]
380
+
381
+ # High-level auto wrappers choose local vs process-pool execution.
382
+ # Each inner author list remains an independent batch-context boundary.
383
+ normalized = detector.normalize_names(names_a, parallel="auto")
384
+ batch_results = detector.process_name_batches(author_batches, parallel="auto")
376
385
 
377
386
  # Reuse workers across many calls
378
387
  with detector.create_persistent_multiprocess_pool(max_workers=6, chunk_size=64) as pool:
379
388
  results_a = pool.normalize_names(names_a)
380
389
  results_b = pool.normalize_names(names_b)
390
+ persistent_batch_results = pool.process_name_batches(author_batches)
381
391
 
382
- # One-off compatibility wrapper with full batch-context semantics
392
+ # One-off temporary pool with process_name_batch batch-context semantics
383
393
  single_batch = detector.process_name_batch_multiprocess(names_a, max_workers=6, chunk_size=64)
384
- return results_a, results_b, single_batch
394
+ return normalized, batch_results, results_a, results_b, persistent_batch_results, single_batch
385
395
 
386
396
  if __name__ == "__main__":
387
397
  main()
@@ -390,6 +400,19 @@ if __name__ == "__main__":
390
400
  Use the `if __name__ == "__main__":` guard in scripts to ensure safe process
391
401
  spawning on Windows and macOS.
392
402
 
403
+ `normalize_names()` has per-name `normalize_name()` semantics. `process_name_batches()`
404
+ is the high-level wrapper for many independent author lists: each inner list
405
+ gets normal batch-format correction, and `parallel="auto"` uses conservative
406
+ platform-aware thresholds to avoid one-off process-startup regressions.
407
+
408
+ For repeated high-throughput calls, keep a persistent pool open. The persistent
409
+ pool exposes `normalize_names()` for independent names and `process_name_batches()`
410
+ for independent author-list batches. The one-off
411
+ `process_name_batch_multiprocess()` method is kept for compatibility and has
412
+ `process_name_batch()` batch-context semantics. For per-name multiprocessing,
413
+ use `normalize_names(..., parallel="always")` or a persistent pool's
414
+ `normalize_names()` method.
415
+
393
416
  ### When to Use Batch Processing
394
417
 
395
418
  * **Academic Papers**: Author lists typically follow consistent formatting
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sinonym"
7
- version = "0.2.8"
7
+ version = "0.3.0"
8
8
  description = "Chinese Name Detection and Normalization Module"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,7 +30,7 @@ dependencies = [
30
30
  "pydantic>=1.10,<2",
31
31
  "pypinyin>=0.44.0",
32
32
  "requests>=2.31",
33
- "scikit-learn>=1.2,<2",
33
+ "scikit-learn==1.7.1",
34
34
  "scipy>=1.10",
35
35
  "skops>=0.9",
36
36
  ]
@@ -61,8 +61,13 @@ include = [
61
61
  include = [
62
62
  "sinonym",
63
63
  "tests",
64
+ "scripts/README.md",
64
65
  "scripts/check_test_status.py",
65
66
  "scripts/name_order_routing_rules.py",
67
+ "scripts/verify_multiprocess.py",
68
+ "scripts/generate_surname_romanizations.py",
69
+ "scripts/generate_name_statistics.py",
70
+ "scripts/train_ml_classifier_for_chinese_vs_japanese.py",
66
71
  "README.md",
67
72
  "pyproject.toml",
68
73
  ]
@@ -82,7 +87,32 @@ select = ["E", "F", "W", "C90", "I", "N", "UP", "YTT", "S", "BLE", "FBT", "B", "
82
87
  ignore = ["S101", "T201"] # Allow assert statements and print statements
83
88
 
84
89
  [tool.ruff.lint.per-file-ignores]
85
- "tests/*" = ["S101", "T201", "ARG", "FBT", "INP001"]
90
+ "tests/*" = ["S101", "T201", "ARG", "FBT", "INP001", "PLR2004"]
91
+ "sinonym/detector.py" = ["C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "SLF001"]
92
+ "sinonym/services/ethnicity.py" = ["C901", "PLR0911", "PLR0912", "PLR0915", "PLR2004", "ARG002", "SLF001"]
93
+ "scripts/generate_acl_data.py" = ["B007", "BLE001", "C901", "F841", "PLC0415", "PLR0912", "PLR0915", "PLR2004", "PLW2901", "PTH123", "SIM102", "SIM108", "SLF001"]
94
+ "scripts/generate_chinese_name_corpus_data.py" = ["ARG001", "BLE001", "C901", "ERA001", "N806", "PLC0415", "PLR0912", "PLR2004", "PTH123", "SLF001", "TRY300"]
95
+ "scripts/profile_multiprocess.py" = ["EM101", "PLR0913", "TRY003"]
96
+ "scripts/profile_run.py" = ["FBT002", "PLR2004"]
97
+ "scripts/profile_threaded.py" = ["E501", "PLR2004", "SIM108"]
98
+ "scripts/train_ml_classifier_for_chinese_vs_japanese.py" = ["N806", "PLC0415"]
99
+ "sinonym/__init__.py" = ["BLE001", "EM102", "PLC0415", "PTH100", "PTH109", "PTH120", "S110", "TRY003"]
100
+ "sinonym/chinese_names_data.py" = ["ERA001"]
101
+ "sinonym/coretypes/config.py" = ["E501", "RUF001", "RUF003"]
102
+ "sinonym/ml_model_components.py" = ["ARG002", "N803", "PLR2004", "UP038"]
103
+ "sinonym/patterns/regex_builders.py" = ["N806", "PLR2004", "RUF001"]
104
+ "sinonym/resources.py" = ["PLC0415", "TC003"]
105
+ "sinonym/services/cache.py" = ["BLE001", "PERF203", "PLC0415", "RUF002", "S112"]
106
+ "sinonym/services/formatting.py" = ["C901", "E741", "EM101", "FIX002", "PLR0911", "PLR0912", "PLR0915", "RUF003", "SIM114", "TD002", "TD003", "TRY003"]
107
+ "sinonym/services/initialization.py" = ["C901", "PLR0911", "PLR0912", "PLR2004", "RUF003", "SIM102", "TC003"]
108
+ "sinonym/services/normalization.py" = ["ARG002", "C901", "FBT001", "FBT002", "FBT003", "PLR0911", "PLR0912", "PLR0915", "PLR2004", "SLF001"]
109
+ "sinonym/services/parsing.py" = ["C901", "COM812", "PLR0912", "PLR0913", "PLR0915", "PLR2004", "RET504", "SIM102", "SIM114"]
110
+ "sinonym/text_processing/compound_detector.py" = ["E501", "PLC0415", "SIM102"]
111
+ "sinonym/text_processing/text_normalizer.py" = ["B019", "RUF001", "S105"]
112
+ "sinonym/text_processing/text_preprocessor.py" = ["ARG002", "PLC0415", "PLR2004", "SLF001"]
113
+ "sinonym/timo/integration_test.py" = ["PT009", "RUF005"]
114
+ "sinonym/utils/string_manipulation.py" = ["C901", "ERA001", "F841", "PLC0415", "PLR0911", "PLR0912", "PLR0915", "PLR2004", "RUF013", "SIM102", "SIM108", "W293"]
115
+ "tests/test_performance.py" = ["C414", "S311", "SIM108", "SLF001"]
86
116
  "tests/test_mixed_scripts.py" = ["RUF001"]
87
117
  "tests/test_non_chinese_rejection.py" = ["RUF001"]
88
118
 
@@ -5,7 +5,7 @@ Utility scripts for benchmarking, profiling, testing, and model training.
5
5
  ## Active Scripts
6
6
 
7
7
  ### `check_test_status.py`
8
- Runs the full test suite and reports individual test case failures with detailed diagnostics. Runs performance tests separately, then exits 0/1 based on whether the failure count and failure signatures match the expected baseline configured in `scripts/check_test_status.py`. Improvements (fewer failures) also pass when the remaining failures are all from the known baseline; regressions or unexpected failure signatures fail.
8
+ Runs the full test suite and reports individual test case failures with detailed diagnostics. Runs performance tests separately, then exits 0/1 based on whether the failure count and failure signatures exactly match the expected baseline configured in `scripts/check_test_status.py`. Regressions, unexpected failure signatures, or missing expected baseline signatures fail until the configured baseline is intentionally updated.
9
9
 
10
10
  ```bash
11
11
  uv run python scripts/check_test_status.py
@@ -47,6 +47,16 @@ Persistent multi-process throughput and parity check. Compares single-process th
47
47
  uv run python scripts/profile_multiprocess.py --names 12000 --warmup 3000 --runs 3 --workers 6 --chunk-size 64
48
48
  ```
49
49
 
50
+ ### `verify_multiprocess.py`
51
+ Cross-platform multiprocessing verifier. Checks correctness parity for local,
52
+ one-shot, auto-wrapper, and persistent-pool paths, then reports throughput for
53
+ flat independent names and independent author-list batches.
54
+
55
+ ```bash
56
+ uv run python scripts/verify_multiprocess.py --json-output scratch/mp_verify_windows.json
57
+ UV_PROJECT_ENVIRONMENT=/tmp/sinonym-wsl-venv uv run python scripts/verify_multiprocess.py --json-output scratch/mp_verify_wsl.json
58
+ ```
59
+
50
60
  ### `train_ml_classifier_for_chinese_vs_japanese.py`
51
61
  Trains the Chinese-vs-Japanese name classifier used in production. Downloads Chinese (~1.2M) and Japanese (~180K) name corpora, trains a scikit-learn pipeline (TF-IDF character n-grams + 20 linguistic heuristic features + logistic regression), and saves the model to `data/chinese_japanese_classifier.skops`.
52
62
 
@@ -30,36 +30,12 @@ EXPECTED_NORMALIZED_NAME_FAILURES = (
30
30
  "Hao Fei",
31
31
  "Fei Hao",
32
32
  ),
33
- (
34
- "tests.test_acl::test_acl_order_preservation[Kun Kuang-Kun Kuang]",
35
- "Kun Kuang",
36
- "Kun Kuang",
37
- "Kuang Kun",
38
- ),
39
33
  (
40
34
  "tests.test_acl::test_acl_order_preservation[Yao Shu-Yao Shu]",
41
35
  "Yao Shu",
42
36
  "Yao Shu",
43
37
  "Shu Yao",
44
38
  ),
45
- (
46
- "tests.test_basic_chinese_names::test_basic_chinese_names[Feng Cha-expected4]",
47
- "Feng Cha",
48
- "Cha Feng",
49
- "Feng Cha",
50
- ),
51
- (
52
- "tests.test_basic_chinese_names::test_basic_chinese_names[He Cha-expected7]",
53
- "He Cha",
54
- "Cha He",
55
- "He Cha",
56
- ),
57
- (
58
- "tests.test_basic_chinese_names::test_basic_chinese_names[Hu Cha-expected9]",
59
- "Hu Cha",
60
- "Cha Hu",
61
- "Hu Cha",
62
- ),
63
39
  (
64
40
  "tests.test_basic_chinese_names::test_basic_chinese_names[Li Gong-expected13]",
65
41
  "Li Gong",
@@ -72,12 +48,6 @@ EXPECTED_NORMALIZED_NAME_FAILURES = (
72
48
  "Wei Gao",
73
49
  "Gao Wei",
74
50
  ),
75
- (
76
- "tests.test_basic_chinese_names::test_basic_chinese_names[Kong Kung-expected63]",
77
- "Kong Kung",
78
- "Kung Kong",
79
- "Kong Kung",
80
- ),
81
51
  (
82
52
  "tests.test_basic_chinese_names::test_basic_chinese_names[Lu Xun-expected75]",
83
53
  "Lu Xun",
@@ -85,23 +55,11 @@ EXPECTED_NORMALIZED_NAME_FAILURES = (
85
55
  "Lu Xun",
86
56
  ),
87
57
  (
88
- "tests.test_basic_chinese_names::test_basic_chinese_names[Qin Shi-expected80]",
89
- "Qin Shi",
90
- "Shi Qin",
91
- "Qin Shi",
92
- ),
93
- (
94
- "tests.test_basic_chinese_names::test_basic_chinese_names[Zhou Xun-expected110]",
58
+ "tests.test_basic_chinese_names::test_basic_chinese_names[Zhou Xun-expected109]",
95
59
  "Zhou Xun",
96
60
  "Xun Zhou",
97
61
  "Zhou Xun",
98
62
  ),
99
- (
100
- "tests.test_compound_names::test_compound_names[Leung Ka Fai-expected7]",
101
- "Leung Ka Fai",
102
- "Ka-Fai Leung",
103
- "Leung-Ka Fai",
104
- ),
105
63
  (
106
64
  "tests.test_misc::test_misc_chinese_names[Jin Hua-expected13]",
107
65
  "Jin Hua",
@@ -120,18 +78,6 @@ EXPECTED_NORMALIZED_NAME_FAILURES = (
120
78
  "Miao Yu",
121
79
  "Yu Miao",
122
80
  ),
123
- (
124
- "tests.test_misc::test_misc_chinese_names[Wen Jing-expected33]",
125
- "Wen Jing",
126
- "Jing Wen",
127
- "Wen Jing",
128
- ),
129
- (
130
- "tests.test_misc::test_misc_chinese_names[Jing Wen-expected34]",
131
- "Jing Wen",
132
- "Jing Wen",
133
- "Wen Jing",
134
- ),
135
81
  (
136
82
  "tests.test_mixed_production_cases::test_mixed_cases[Gui Rui-expected_result6]",
137
83
  "Gui Rui",
@@ -144,24 +90,12 @@ EXPECTED_NORMALIZED_NAME_FAILURES = (
144
90
  "Yao Shu",
145
91
  "Shu Yao",
146
92
  ),
147
- (
148
- "tests.test_mixed_production_cases::test_mixed_cases[Huang Yu Chang-expected_result19]",
149
- "Huang Yu Chang",
150
- "Yu-Chang Huang",
151
- "Huang-Yu Chang",
152
- ),
153
93
  (
154
94
  "tests.test_mixed_production_cases::test_mixed_cases[Jia Jian Feng-expected_result24]",
155
95
  "Jia Jian Feng",
156
96
  "Jian-Feng Jia",
157
97
  "Jia-Jian Feng",
158
98
  ),
159
- (
160
- "tests.test_mixed_production_cases::test_mixed_cases[Fan Jia Liang-expected_result41]",
161
- "Fan Jia Liang",
162
- "Jia-Liang Fan",
163
- "Fan-Jia Liang",
164
- ),
165
99
  (
166
100
  "tests.test_mixed_production_cases::test_mixed_cases[Wei Wen Xing-expected_result48]",
167
101
  "Wei Wen Xing",
@@ -174,18 +108,6 @@ EXPECTED_NORMALIZED_NAME_FAILURES = (
174
108
  "Zhao Xi",
175
109
  "Xi Zhao",
176
110
  ),
177
- (
178
- "tests.test_mixed_production_cases::test_mixed_cases[xu feng-expected_result120]",
179
- "xu feng",
180
- "Feng Xu",
181
- "Xu Feng",
182
- ),
183
- (
184
- "tests.test_mixed_production_cases::test_mixed_cases[yang guang-expected_result121]",
185
- "yang guang",
186
- "Guang Yang",
187
- "Yang Guang",
188
- ),
189
111
  )
190
112
  EXPECTED_FAILURES = len(EXPECTED_NORMALIZED_NAME_FAILURES)
191
113
  EXPECTED_FAILURE_SIGNATURES = tuple(
@@ -379,6 +301,20 @@ def unexpected_failure_signatures(failures: Iterable[FailureDetail]) -> list[Fai
379
301
  return sorted(unexpected, key=_failure_signature)
380
302
 
381
303
 
304
+ def missing_expected_failure_signatures(failures: Iterable[FailureDetail]) -> list[FailureDetail]:
305
+ """Return known baseline failures that were not present in the current failure list."""
306
+ observed_counts = Counter(_failure_signature(failure) for failure in failures)
307
+ missing = []
308
+ for nodeid, kind, message in EXPECTED_FAILURE_SIGNATURES:
309
+ signature = (nodeid, kind, message)
310
+ if observed_counts[signature] > 0:
311
+ observed_counts[signature] -= 1
312
+ else:
313
+ missing.append(FailureDetail(nodeid, kind, message))
314
+
315
+ return sorted(missing, key=_failure_signature)
316
+
317
+
382
318
  def _unexpected_failure_sample(failures: list[FailureDetail]) -> str:
383
319
  """Return a concise sample of unexpected failure signatures for the status line."""
384
320
  samples = [f"{failure.nodeid} ({failure.kind}: {failure.message})" for failure in failures[:UNEXPECTED_FAILURE_SAMPLE_SIZE]]
@@ -390,6 +326,32 @@ def _unexpected_failure_sample(failures: list[FailureDetail]) -> str:
390
326
  return f"{', '.join(samples)}{suffix}"
391
327
 
392
328
 
329
+ def _execution_status_error(
330
+ total_failures: int,
331
+ *,
332
+ pytest_returncode: int | None,
333
+ junit_error: str | None,
334
+ ) -> str | None:
335
+ """Return a failure message if the run cannot be scored against the baseline.
336
+
337
+ These conditions describe a run that never produced a trustworthy JUnit
338
+ failure list (crash, non-baseline exit code, or a returncode/total mismatch),
339
+ so signature-level comparison would be meaningless. Returns None when the run
340
+ is well-formed enough to be scored.
341
+ """
342
+ if pytest_returncode is None:
343
+ return "Test execution failed before pytest returned a status."
344
+ if junit_error is not None:
345
+ return junit_error
346
+ if pytest_returncode not in PYTEST_BASELINE_RETURN_CODES:
347
+ return f"Pytest execution failed with exit code {pytest_returncode}."
348
+ if pytest_returncode == 1 and total_failures == 0:
349
+ return "Pytest failed without JUnit-reported failures."
350
+ if pytest_returncode == 0 and total_failures > 0:
351
+ return "JUnit reported failures even though pytest exited successfully."
352
+ return None
353
+
354
+
393
355
  def status_exit_decision(
394
356
  total_failures: int,
395
357
  *,
@@ -398,40 +360,47 @@ def status_exit_decision(
398
360
  junit_error: str | None = None,
399
361
  failures: Iterable[FailureDetail] | None = None,
400
362
  ) -> tuple[int, str]:
401
- """Return process exit code and final status message."""
402
- unexpected_signatures = unexpected_failure_signatures(failures or ())
403
- if pytest_returncode is None:
404
- exit_code = 1
405
- status_message = "Test execution failed before pytest returned a status."
406
- elif junit_error is not None:
407
- exit_code = 1
408
- status_message = junit_error
409
- elif pytest_returncode not in PYTEST_BASELINE_RETURN_CODES:
410
- exit_code = 1
411
- status_message = f"Pytest execution failed with exit code {pytest_returncode}."
412
- elif pytest_returncode == 1 and total_failures == 0:
413
- exit_code = 1
414
- status_message = "Pytest failed without JUnit-reported failures."
415
- elif pytest_returncode == 0 and total_failures > 0:
416
- exit_code = 1
417
- status_message = "JUnit reported failures even though pytest exited successfully."
363
+ """Return process exit code and final status message.
364
+
365
+ The exit-0 (baseline / improvement) paths are only reached once the concrete
366
+ failure list has been validated against ``total_failures`` and cleared of any
367
+ signature outside the known baseline. A missing or length-inconsistent list is
368
+ treated as caller misuse and fails loudly rather than being guessed into a
369
+ false pass.
370
+ """
371
+ execution_error = _execution_status_error(
372
+ total_failures,
373
+ pytest_returncode=pytest_returncode,
374
+ junit_error=junit_error,
375
+ )
376
+ # Past the execution guard signatures must be checked, so the concrete list is
377
+ # required and must agree with the reported total before it can be scored.
378
+ failures_list = None if failures is None else list(failures)
379
+ length_ok = failures_list is not None and len(failures_list) == total_failures
380
+ unexpected_signatures = unexpected_failure_signatures(failures_list) if length_ok else []
381
+ missing_signatures = missing_expected_failure_signatures(failures_list) if length_ok else []
382
+
383
+ if execution_error is not None:
384
+ exit_code, status_message = 1, execution_error
385
+ elif failures_list is None:
386
+ exit_code, status_message = 1, "Cannot verify failures against the baseline: no failure list was provided."
387
+ elif len(failures_list) != total_failures:
388
+ exit_code, status_message = (
389
+ 1,
390
+ (f"Failure count mismatch: received {len(failures_list)} JUnit failures but total_failures={total_failures}."),
391
+ )
392
+ elif total_failures > EXPECTED_FAILURES:
393
+ exit_code, status_message = 1, f"REGRESSION! Too many failures ({total_failures} > {EXPECTED_FAILURES})"
418
394
  elif unexpected_signatures:
419
- exit_code = 1
420
- status_message = f"Unexpected pytest failure signatures: {_unexpected_failure_sample(unexpected_signatures)}"
395
+ sample = _unexpected_failure_sample(unexpected_signatures)
396
+ exit_code, status_message = 1, f"Unexpected pytest failure signatures: {sample}"
397
+ elif missing_signatures:
398
+ sample = _unexpected_failure_sample(missing_signatures)
399
+ exit_code, status_message = 1, f"Missing expected pytest failure signatures: {sample}"
421
400
  elif not perf_passed:
422
- exit_code = 1
423
- status_message = "Performance tests failed!"
424
- elif total_failures > EXPECTED_FAILURES:
425
- exit_code = 1
426
- status_message = f"REGRESSION! Too many failures ({total_failures} > EXPECTED_FAILURES)"
427
- elif total_failures == EXPECTED_FAILURES:
428
- exit_code = 0
429
- status_message = "Tests are at expected baseline (EXPECTED_FAILURES failures, performance OK)"
401
+ exit_code, status_message = 1, "Performance tests failed!"
430
402
  else:
431
- exit_code = 0
432
- status_message = (
433
- f"IMPROVEMENT! Tests are better than baseline ({total_failures} < EXPECTED_FAILURES failures, performance OK)"
434
- )
403
+ exit_code, status_message = 0, f"Tests are at expected baseline ({EXPECTED_FAILURES} failures, performance OK)"
435
404
 
436
405
  return exit_code, status_message
437
406
 
@@ -503,7 +472,7 @@ def main() -> None:
503
472
  junit_error=junit_error,
504
473
  failures=failures,
505
474
  )
506
- print(f"\n{status_message}")
475
+ print(f"\n{_safe_for_console(status_message)}")
507
476
  sys.exit(exit_code)
508
477
 
509
478