dnakit 0.1.0.dev0__py3-none-any.whl

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 (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,211 @@
1
+ """Configurable transparent aggregation of evaluation and metric results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import TypeAlias
9
+
10
+ from dnakit.core import MetricResult
11
+ from dnakit.exceptions import ConfigurationError
12
+
13
+ from ._shared import report
14
+ from .config import ScorecardConfig, ScoreRule
15
+ from .results import EvaluationReport
16
+
17
+ ScoreInput: TypeAlias = EvaluationReport | MetricResult[object] | int | float | None
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class _ResolvedScore:
22
+ name: str
23
+ raw_value: float | None
24
+ source: str
25
+ metric: str | None
26
+ source_method: str | None
27
+ source_algorithm_version: str | None
28
+
29
+
30
+ def _number(value: object, name: str) -> float:
31
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
32
+ raise ConfigurationError(
33
+ "Scorecard values must be finite numbers.",
34
+ code="INVALID_SCORECARD_VALUE",
35
+ context={"component": name, "value": value},
36
+ )
37
+ return float(value)
38
+
39
+
40
+ def _resolve(name: str, value: object, rule: ScoreRule) -> _ResolvedScore:
41
+ if value is None:
42
+ return _ResolvedScore(name, None, "missing", rule.metric, None, None)
43
+ if isinstance(value, EvaluationReport):
44
+ metric_name = rule.metric or "score"
45
+ if metric_name not in value.metrics:
46
+ return _ResolvedScore(
47
+ name,
48
+ None,
49
+ f"EvaluationReport:{value.name}",
50
+ metric_name,
51
+ value.method,
52
+ value.algorithm_version,
53
+ )
54
+ return _ResolvedScore(
55
+ name,
56
+ _number(value.metrics[metric_name], name),
57
+ f"EvaluationReport:{value.name}",
58
+ metric_name,
59
+ value.method,
60
+ value.algorithm_version,
61
+ )
62
+ if isinstance(value, MetricResult):
63
+ if rule.metric is not None:
64
+ raise ConfigurationError(
65
+ "ScoreRule.metric is only used to select EvaluationReport.metrics.",
66
+ code="INVALID_SCORECARD_RULE",
67
+ context={"component": name},
68
+ )
69
+ return _ResolvedScore(
70
+ name,
71
+ _number(value.value, name),
72
+ f"MetricResult:{value.name}",
73
+ None,
74
+ value.method,
75
+ value.algorithm_version,
76
+ )
77
+ return _ResolvedScore(name, _number(value, name), "numeric", None, None, None)
78
+
79
+
80
+ def _normalize(value: float, rule: ScoreRule) -> tuple[float, bool]:
81
+ unclipped = (value - rule.minimum) / (rule.maximum - rule.minimum)
82
+ if rule.direction == "lower_is_better":
83
+ unclipped = 1.0 - unclipped
84
+ return min(1.0, max(0.0, unclipped)), not 0.0 <= unclipped <= 1.0
85
+
86
+
87
+ def evaluate_scorecard(
88
+ values: Mapping[str, object],
89
+ *,
90
+ config: ScorecardConfig,
91
+ ) -> EvaluationReport:
92
+ """Normalize and aggregate named inputs while retaining every contribution."""
93
+
94
+ if not isinstance(values, Mapping):
95
+ raise ConfigurationError("values must be a mapping.", code="INVALID_SCORECARD_INPUT")
96
+ if not isinstance(config, ScorecardConfig):
97
+ raise TypeError("config must be ScorecardConfig.")
98
+ unknown = set(values) - set(config.rules)
99
+ if unknown:
100
+ raise ConfigurationError(
101
+ "Scorecard values contain unknown components.",
102
+ code="UNKNOWN_SCORECARD_COMPONENT",
103
+ context={"components": sorted(unknown)},
104
+ )
105
+ resolved = tuple(_resolve(name, values.get(name), rule) for name, rule in config.rules.items())
106
+ missing = tuple(item.name for item in resolved if item.raw_value is None)
107
+ if missing and config.missing_policy == "error":
108
+ raise ConfigurationError(
109
+ "Scorecard is missing required component values.",
110
+ code="MISSING_SCORECARD_COMPONENT",
111
+ context={"components": missing},
112
+ )
113
+ contributions: list[dict[str, object]] = []
114
+ weighted_sum = 0.0
115
+ weight_sum = 0.0
116
+ for item in resolved:
117
+ rule = config.rules[item.name]
118
+ if not isinstance(rule, ScoreRule):
119
+ raise AssertionError("ScorecardConfig accepted a non-ScoreRule value.")
120
+ if item.raw_value is None and config.missing_policy == "omit":
121
+ contributions.append(
122
+ {
123
+ "name": item.name,
124
+ "source": item.source,
125
+ "source_metric": item.metric,
126
+ "source_method": item.source_method,
127
+ "source_algorithm_version": item.source_algorithm_version,
128
+ "raw_value": None,
129
+ "normalized_value": None,
130
+ "weight": rule.weight,
131
+ "weighted_contribution": None,
132
+ "direction": rule.direction,
133
+ "minimum": rule.minimum,
134
+ "maximum": rule.maximum,
135
+ "status": "omitted",
136
+ "clipped": False,
137
+ }
138
+ )
139
+ continue
140
+ raw_value = 0.0 if item.raw_value is None else item.raw_value
141
+ normalized, clipped = (
142
+ (0.0, False) if item.raw_value is None else _normalize(raw_value, rule)
143
+ )
144
+ contribution = normalized * rule.weight
145
+ weighted_sum += contribution
146
+ weight_sum += rule.weight
147
+ contributions.append(
148
+ {
149
+ "name": item.name,
150
+ "source": item.source,
151
+ "source_metric": item.metric,
152
+ "source_method": item.source_method,
153
+ "source_algorithm_version": item.source_algorithm_version,
154
+ "raw_value": item.raw_value,
155
+ "imputed_value": raw_value if item.raw_value is None else None,
156
+ "normalized_value": normalized,
157
+ "weight": rule.weight,
158
+ "weighted_contribution": contribution,
159
+ "direction": rule.direction,
160
+ "minimum": rule.minimum,
161
+ "maximum": rule.maximum,
162
+ "status": "zero-imputed" if item.raw_value is None else "included",
163
+ "clipped": clipped,
164
+ }
165
+ )
166
+ if weight_sum == 0:
167
+ raise ConfigurationError(
168
+ "No scorecard component remains after applying missing_policy.",
169
+ code="EMPTY_SCORECARD",
170
+ )
171
+ score = weighted_sum / weight_sum
172
+ status = (
173
+ "pass"
174
+ if score >= config.pass_score
175
+ else ("warning" if score >= config.warning_score else "fail")
176
+ )
177
+ return report(
178
+ name="scorecard",
179
+ method="weighted-clipped-min-max-normalization",
180
+ version="eval-scorecard-v1",
181
+ parameters={
182
+ "rules": {
183
+ name: {
184
+ "direction": rule.direction,
185
+ "weight": rule.weight,
186
+ "minimum": rule.minimum,
187
+ "maximum": rule.maximum,
188
+ "metric": rule.metric,
189
+ }
190
+ for name, rule in config.rules.items()
191
+ },
192
+ "formula": "sum(weight_i * clipped_normalized_i) / sum(included_weight_i)",
193
+ "higher_normalization": "clip((x-min)/(max-min),0,1)",
194
+ "lower_normalization": "clip(1-(x-min)/(max-min),0,1)",
195
+ "missing_policy": config.missing_policy,
196
+ "zero_missing_definition": "normalized contribution is zero before weighting",
197
+ "warning_score": config.warning_score,
198
+ "pass_score": config.pass_score,
199
+ },
200
+ metrics={
201
+ "score": score,
202
+ "status": status,
203
+ "weighted_sum": weighted_sum,
204
+ "included_weight_sum": weight_sum,
205
+ "missing_components": missing,
206
+ "contributions": tuple(contributions),
207
+ },
208
+ )
209
+
210
+
211
+ __all__ = ["ScoreInput", "evaluate_scorecard"]
@@ -0,0 +1,403 @@
1
+ """Single-sequence validity, ambiguity, complexity, and quality evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+
8
+ from dnakit.core import Gap, IssueSeverity
9
+ from dnakit.core._json import FrozenDict, to_json_compatible
10
+ from dnakit.descriptors import (
11
+ exact_repeat_fraction,
12
+ homopolymer_runs,
13
+ linguistic_complexity,
14
+ shannon_entropy,
15
+ )
16
+ from dnakit.standardize import ValidationConfig, validate
17
+
18
+ from ._shared import (
19
+ EvaluationInput,
20
+ InputItem,
21
+ aggregate_numeric,
22
+ as_float,
23
+ issue,
24
+ materialize_input,
25
+ report,
26
+ )
27
+ from .config import (
28
+ AmbiguityEvaluationConfig,
29
+ ComplexityEvaluationConfig,
30
+ EvaluationLimits,
31
+ QualityEvaluationConfig,
32
+ )
33
+ from .results import EvaluationEntry, EvaluationReport
34
+
35
+
36
+ def _validation_entry(item: InputItem, index: int, config: ValidationConfig) -> EvaluationEntry:
37
+ result = validate(item.record or item.sequence, config=config)
38
+ return EvaluationEntry(
39
+ item.subject_id,
40
+ index,
41
+ FrozenDict(
42
+ {
43
+ "is_valid": result.is_valid,
44
+ "symbol_length": result.symbol_length,
45
+ "coordinate_span": result.coordinate_span,
46
+ "ambiguity_count": result.ambiguity.total_count,
47
+ "ambiguity_fraction": result.ambiguity.fraction,
48
+ "quality": to_json_compatible(result.quality),
49
+ }
50
+ ),
51
+ result.issues,
52
+ )
53
+
54
+
55
+ def evaluate_validity(
56
+ value: EvaluationInput,
57
+ *,
58
+ config: ValidationConfig | None = None,
59
+ limits: EvaluationLimits | None = None,
60
+ ) -> EvaluationReport:
61
+ """Evaluate object-level alphabet, length, gap, and optional record constraints."""
62
+
63
+ resolved = ValidationConfig() if config is None else config
64
+ resolved_limits = EvaluationLimits() if limits is None else limits
65
+ if not isinstance(resolved, ValidationConfig):
66
+ raise TypeError("config must be ValidationConfig or None.")
67
+ if not isinstance(resolved_limits, EvaluationLimits):
68
+ raise TypeError("limits must be EvaluationLimits or None.")
69
+ items = materialize_input(value, limits=resolved_limits)
70
+ entries = tuple(_validation_entry(item, index, resolved) for index, item in enumerate(items))
71
+ valid_count = sum(entry.metrics["is_valid"] is True for entry in entries)
72
+ return report(
73
+ name="sequence_validity",
74
+ method="dnakit-standardize-validation",
75
+ version="eval-validity-v1",
76
+ parameters={"validation": resolved, "limits": resolved_limits},
77
+ metrics={
78
+ "record_count": len(entries),
79
+ "valid_count": valid_count,
80
+ "invalid_count": len(entries) - valid_count,
81
+ "valid_fraction": valid_count / len(entries) if entries else None,
82
+ },
83
+ entries=entries,
84
+ )
85
+
86
+
87
+ def _ambiguity_entry(
88
+ item: InputItem,
89
+ index: int,
90
+ config: AmbiguityEvaluationConfig,
91
+ ) -> EvaluationEntry:
92
+ sequence = item.sequence
93
+ positions = tuple(
94
+ index for index, symbol in enumerate(sequence.symbols) if symbol not in "ACGT"
95
+ )
96
+ counts = Counter(sequence.symbols[position] for position in positions)
97
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
98
+ if config.gap_denominator_policy == "error" and gaps:
99
+ from dnakit.exceptions import UnsupportedGapOperationError
100
+
101
+ raise UnsupportedGapOperationError(
102
+ "Ambiguity evaluation is configured to reject Gap objects.",
103
+ code="AMBIGUITY_GAP_REJECTED",
104
+ context={"subject_id": item.subject_id},
105
+ )
106
+ denominator: int | None = sequence.symbol_length
107
+ if config.gap_denominator_policy == "include_known":
108
+ denominator = sequence.coordinate_span
109
+ weighted_count = math.fsum(
110
+ counts[symbol] * float(config.symbol_weights[symbol]) for symbol in sorted(counts)
111
+ )
112
+ fraction = len(positions) / denominator if denominator else (0.0 if denominator == 0 else None)
113
+ weighted_fraction = (
114
+ weighted_count / denominator if denominator else (0.0 if denominator == 0 else None)
115
+ )
116
+ entry_issues = []
117
+ if denominator is None:
118
+ entry_issues.append(
119
+ issue(
120
+ "EVAL_AMBIGUITY_DENOMINATOR_UNKNOWN",
121
+ IssueSeverity.WARNING,
122
+ "Ambiguity fraction is undefined because a gap length is unknown.",
123
+ gap_denominator_policy=config.gap_denominator_policy,
124
+ )
125
+ )
126
+ elif fraction is not None and fraction > config.max_fraction:
127
+ entry_issues.append(
128
+ issue(
129
+ "EVAL_AMBIGUITY_HIGH",
130
+ IssueSeverity.WARNING,
131
+ "Ambiguity fraction exceeds the configured threshold.",
132
+ fraction=fraction,
133
+ maximum=config.max_fraction,
134
+ )
135
+ )
136
+ return EvaluationEntry(
137
+ item.subject_id,
138
+ index,
139
+ FrozenDict(
140
+ {
141
+ "ambiguity_count": len(positions),
142
+ "ambiguity_fraction": fraction,
143
+ "weighted_ambiguity_count": weighted_count,
144
+ "weighted_ambiguity_fraction": weighted_fraction,
145
+ "denominator": denominator,
146
+ "positions": positions,
147
+ "counts_by_symbol": {symbol: counts[symbol] for symbol in sorted(counts)},
148
+ "gap_count": len(gaps),
149
+ "unknown_gap_count": sum(gap.length is None for gap in gaps),
150
+ "passes_threshold": fraction is not None and fraction <= config.max_fraction,
151
+ }
152
+ ),
153
+ tuple(entry_issues),
154
+ )
155
+
156
+
157
+ def evaluate_ambiguity(
158
+ value: EvaluationInput,
159
+ *,
160
+ config: AmbiguityEvaluationConfig | None = None,
161
+ ) -> EvaluationReport:
162
+ """Report ambiguity symbols, positions, weighted impact, and explicit denominator."""
163
+
164
+ resolved = AmbiguityEvaluationConfig() if config is None else config
165
+ if not isinstance(resolved, AmbiguityEvaluationConfig):
166
+ raise TypeError("config must be AmbiguityEvaluationConfig or None.")
167
+ items = materialize_input(value, limits=resolved.limits)
168
+ entries = tuple(_ambiguity_entry(item, index, resolved) for index, item in enumerate(items))
169
+ return report(
170
+ name="ambiguity",
171
+ method="weighted-iupac-symbol-fraction",
172
+ version="eval-ambiguity-v1",
173
+ parameters={
174
+ "max_fraction": resolved.max_fraction,
175
+ "symbol_weights": resolved.symbol_weights,
176
+ "gap_denominator_policy": resolved.gap_denominator_policy,
177
+ "limits": resolved.limits,
178
+ "coordinate_system": "0-based-symbol",
179
+ },
180
+ metrics={
181
+ "record_count": len(entries),
182
+ "ambiguity_fraction_summary": aggregate_numeric(entries, "ambiguity_fraction"),
183
+ "weighted_ambiguity_fraction_summary": aggregate_numeric(
184
+ entries, "weighted_ambiguity_fraction"
185
+ ),
186
+ "failing_count": sum(entry.metrics["passes_threshold"] is False for entry in entries),
187
+ },
188
+ entries=entries,
189
+ )
190
+
191
+
192
+ def _complexity_entry(
193
+ item: InputItem,
194
+ index: int,
195
+ config: ComplexityEvaluationConfig,
196
+ ) -> EvaluationEntry:
197
+ entropy = shannon_entropy(item.sequence, ambiguity_policy="ignore")
198
+ linguistic = linguistic_complexity(
199
+ item.sequence,
200
+ max_word_size=config.max_word_size,
201
+ ambiguity_policy="ignore",
202
+ max_observations=config.max_observations_per_sequence,
203
+ )
204
+ repeat = exact_repeat_fraction(
205
+ item.sequence,
206
+ max_unit_length=config.max_repeat_unit,
207
+ min_repeats=config.min_repeat_count,
208
+ ambiguity_policy="ignore",
209
+ max_comparisons=config.max_comparisons_per_sequence,
210
+ )
211
+ homopolymer = homopolymer_runs(item.sequence, ambiguity_policy="ignore")
212
+ entropy_normalized = min(1.0, max(0.0, entropy.entropy / 2.0))
213
+ homopolymer_cleanliness = (
214
+ 1.0
215
+ if homopolymer.longest_length <= config.acceptable_homopolymer_length
216
+ else config.acceptable_homopolymer_length / homopolymer.longest_length
217
+ )
218
+ components = {
219
+ "entropy": entropy_normalized,
220
+ "linguistic": linguistic.score,
221
+ "repeat_cleanliness": 1.0 - repeat.repeat_fraction,
222
+ "homopolymer_cleanliness": homopolymer_cleanliness,
223
+ }
224
+ denominator = math.fsum(float(config.weights[name]) for name in components)
225
+ score = (
226
+ math.fsum(components[name] * float(config.weights[name]) for name in components)
227
+ / denominator
228
+ )
229
+ return EvaluationEntry(
230
+ item.subject_id,
231
+ index,
232
+ FrozenDict(
233
+ {
234
+ "score": score,
235
+ "components": components,
236
+ "shannon_entropy_bits": entropy.entropy,
237
+ "linguistic_complexity": linguistic.score,
238
+ "repeat_fraction": repeat.repeat_fraction,
239
+ "longest_homopolymer": homopolymer.longest_length,
240
+ "ignored_ambiguity_count": item.sequence.ambiguity_count,
241
+ "repeat_comparisons": repeat.comparisons,
242
+ "complexity_observations": linguistic.observation_count,
243
+ }
244
+ ),
245
+ )
246
+
247
+
248
+ def evaluate_complexity(
249
+ value: EvaluationInput,
250
+ *,
251
+ config: ComplexityEvaluationConfig | None = None,
252
+ ) -> EvaluationReport:
253
+ """Combine normalized entropy, vocabulary, repeat, and homopolymer components."""
254
+
255
+ resolved = ComplexityEvaluationConfig() if config is None else config
256
+ if not isinstance(resolved, ComplexityEvaluationConfig):
257
+ raise TypeError("config must be ComplexityEvaluationConfig or None.")
258
+ items = materialize_input(value, limits=resolved.limits)
259
+ entries = tuple(_complexity_entry(item, index, resolved) for index, item in enumerate(items))
260
+ return report(
261
+ name="complexity",
262
+ method="weighted-normalized-transparent-components",
263
+ version="eval-complexity-v1",
264
+ parameters={
265
+ "weights": resolved.weights,
266
+ "entropy_normalization": "min(1,max(0,H_base2/2))",
267
+ "repeat_cleanliness": "1-exact_tandem_repeat_union_fraction",
268
+ "homopolymer_cleanliness": "1 if longest<=acceptable else acceptable/longest",
269
+ "max_word_size": resolved.max_word_size,
270
+ "max_repeat_unit": resolved.max_repeat_unit,
271
+ "min_repeat_count": resolved.min_repeat_count,
272
+ "acceptable_homopolymer_length": resolved.acceptable_homopolymer_length,
273
+ "max_observations_per_sequence": resolved.max_observations_per_sequence,
274
+ "max_comparisons_per_sequence": resolved.max_comparisons_per_sequence,
275
+ "limits": resolved.limits,
276
+ },
277
+ metrics={
278
+ "record_count": len(entries),
279
+ "score_summary": aggregate_numeric(entries, "score"),
280
+ "score": (
281
+ math.fsum(as_float(entry.metrics, "score") for entry in entries) / len(entries)
282
+ if entries
283
+ else None
284
+ ),
285
+ },
286
+ entries=entries,
287
+ )
288
+
289
+
290
+ def evaluate_quality(
291
+ value: EvaluationInput,
292
+ *,
293
+ config: QualityEvaluationConfig | None = None,
294
+ ) -> EvaluationReport:
295
+ """Combine validity, ambiguity, complexity, length, and record completeness."""
296
+
297
+ resolved = QualityEvaluationConfig() if config is None else config
298
+ if not isinstance(resolved, QualityEvaluationConfig):
299
+ raise TypeError("config must be QualityEvaluationConfig or None.")
300
+ items = materialize_input(value, limits=resolved.complexity.limits)
301
+ entries: list[EvaluationEntry] = []
302
+ validation_config = ValidationConfig(
303
+ allow_empty=resolved.min_length == 0,
304
+ min_length=resolved.min_length,
305
+ max_length=resolved.max_length,
306
+ max_ambiguity_fraction=None,
307
+ )
308
+ for index, item in enumerate(items):
309
+ validity = _validation_entry(item, index, validation_config)
310
+ ambiguity = _ambiguity_entry(item, index, resolved.ambiguity)
311
+ complexity = _complexity_entry(item, index, resolved.complexity)
312
+ length = item.sequence.symbol_length
313
+ length_ok = length >= resolved.min_length and (
314
+ resolved.max_length is None or length <= resolved.max_length
315
+ )
316
+ completeness = 1.0
317
+ if item.record is not None and "phred_quality" in item.record.letter_annotations:
318
+ quality_values = item.record.letter_annotations["phred_quality"]
319
+ completeness = (
320
+ sum(value >= 20 for value in quality_values) / len(quality_values)
321
+ if quality_values
322
+ else 1.0
323
+ )
324
+ ambiguity_value = ambiguity.metrics["weighted_ambiguity_fraction"]
325
+ ambiguity_score = (
326
+ 0.0
327
+ if ambiguity_value is None
328
+ else 1.0 - min(1.0, as_float(ambiguity.metrics, "weighted_ambiguity_fraction"))
329
+ )
330
+ components = {
331
+ "validity": float(validity.metrics["is_valid"] is True),
332
+ "ambiguity": ambiguity_score,
333
+ "complexity": as_float(complexity.metrics, "score"),
334
+ "length": float(length_ok),
335
+ "completeness": completeness,
336
+ }
337
+ weight_sum = math.fsum(float(resolved.weights[name]) for name in components)
338
+ score = (
339
+ math.fsum(value * float(resolved.weights[name]) for name, value in components.items())
340
+ / weight_sum
341
+ )
342
+ status = (
343
+ "pass"
344
+ if score >= resolved.pass_score
345
+ else ("warning" if score >= resolved.warning_score else "fail")
346
+ )
347
+ entries.append(
348
+ EvaluationEntry(
349
+ item.subject_id,
350
+ index,
351
+ FrozenDict(
352
+ {
353
+ "score": score,
354
+ "status": status,
355
+ "components": components,
356
+ "symbol_length": length,
357
+ "phred_q20_fraction": completeness if item.record is not None else None,
358
+ }
359
+ ),
360
+ validity.issues + ambiguity.issues,
361
+ )
362
+ )
363
+ materialized = tuple(entries)
364
+ return report(
365
+ name="sequence_quality",
366
+ method="weighted-validity-ambiguity-complexity-length-completeness",
367
+ version="eval-quality-v1",
368
+ parameters={
369
+ "weights": resolved.weights,
370
+ "min_length": resolved.min_length,
371
+ "max_length": resolved.max_length,
372
+ "warning_score": resolved.warning_score,
373
+ "pass_score": resolved.pass_score,
374
+ "phred_completeness_rule": "fraction of provided phred_quality values >=20",
375
+ "missing_phred_policy": "neutral completeness=1",
376
+ "undefined_ambiguity_policy": "ambiguity component is zero",
377
+ "length_definition": "symbol_length excluding Gap spans",
378
+ "ambiguity_config": resolved.ambiguity,
379
+ "complexity_config": resolved.complexity,
380
+ },
381
+ metrics={
382
+ "record_count": len(materialized),
383
+ "score_summary": aggregate_numeric(materialized, "score"),
384
+ "score": (
385
+ math.fsum(as_float(entry.metrics, "score") for entry in materialized)
386
+ / len(materialized)
387
+ if materialized
388
+ else None
389
+ ),
390
+ "pass_count": sum(entry.metrics["status"] == "pass" for entry in materialized),
391
+ "warning_count": sum(entry.metrics["status"] == "warning" for entry in materialized),
392
+ "fail_count": sum(entry.metrics["status"] == "fail" for entry in materialized),
393
+ },
394
+ entries=materialized,
395
+ )
396
+
397
+
398
+ __all__ = [
399
+ "evaluate_ambiguity",
400
+ "evaluate_complexity",
401
+ "evaluate_quality",
402
+ "evaluate_validity",
403
+ ]