pyplasmode 1.0.0__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.
@@ -0,0 +1,623 @@
1
+ """Support-aware nominations and truth-blind matched recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter, defaultdict
6
+ from dataclasses import dataclass
7
+ from math import comb, factorial
8
+ from typing import Literal
9
+
10
+ import numpy as np
11
+ from numpy.typing import ArrayLike, NDArray
12
+ from scipy.stats import hypergeom
13
+
14
+ from pyplasmode._validation import identities, integer
15
+ from pyplasmode.results import FractionalRecovery, MatchedRecovery, OutcomeSpecificity
16
+ from pyplasmode.truth import MaterializedTruth
17
+
18
+ type RecoveryEstimand = Literal["exact", "group", "module", "interaction"]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class FractionalNomination:
23
+ """One tie-aware nomination over a declared positive-support universe."""
24
+
25
+ feature_ids: tuple[str, ...]
26
+ membership: NDArray[np.float64]
27
+ requested_depth: int
28
+ positive_support: tuple[str, ...]
29
+
30
+ def __post_init__(self) -> None:
31
+ """Require aligned fractional membership without hidden padding."""
32
+ integer(self.requested_depth, "requested_depth", minimum=1)
33
+ identities(self.feature_ids, "feature_ids")
34
+ identities(self.positive_support, "positive_support", allow_empty=True)
35
+ values = np.asarray(self.membership, dtype=np.float64)
36
+ support = set(self.positive_support)
37
+ positions = {feature: index for index, feature in enumerate(self.feature_ids)}
38
+ boundary = values[(values > 0.0) & (values < 1.0)]
39
+ if (
40
+ values.shape != (len(self.feature_ids),)
41
+ or not np.isfinite(values).all()
42
+ or np.any((values < 0.0) | (values > 1.0))
43
+ or self.requested_depth > len(self.feature_ids)
44
+ or not support.issubset(positions)
45
+ or any(
46
+ values[index] > 0.0 and feature not in support
47
+ for index, feature in enumerate(self.feature_ids)
48
+ )
49
+ or (
50
+ boundary.size > 0
51
+ and (
52
+ not np.allclose(boundary, boundary[0], atol=1e-12, rtol=0.0)
53
+ or not np.isclose(float(np.sum(boundary)), round(float(np.sum(boundary))))
54
+ )
55
+ )
56
+ or not np.isclose(
57
+ float(np.sum(values)),
58
+ min(self.requested_depth, len(self.positive_support)),
59
+ atol=1e-12,
60
+ )
61
+ ):
62
+ raise ValueError("fractional nomination is invalid")
63
+ frozen = values.copy()
64
+ frozen.setflags(write=False)
65
+ object.__setattr__(self, "membership", frozen)
66
+
67
+ @property
68
+ def achieved_size(self) -> int:
69
+ """Return the number of proteins with positive fractional membership."""
70
+ return int(np.count_nonzero(self.membership > 0.0))
71
+
72
+ @property
73
+ def selected_features(self) -> tuple[str, ...]:
74
+ """Return the depth-limited features with non-zero selection probability."""
75
+ return tuple(
76
+ feature
77
+ for feature, weight in zip(self.feature_ids, self.membership, strict=True)
78
+ if weight > 0.0
79
+ )
80
+
81
+ @property
82
+ def membership_budget(self) -> float:
83
+ """Return the realised protein-membership budget."""
84
+ return float(np.sum(self.membership))
85
+
86
+
87
+ def nomination_from_scores(
88
+ feature_ids: tuple[str, ...],
89
+ scores: ArrayLike,
90
+ *,
91
+ depth: int,
92
+ ) -> FractionalNomination:
93
+ """Construct a positive-support nomination with fractional boundary ties.
94
+
95
+ Args:
96
+ feature_ids: Unique feature identities aligned to scores.
97
+ scores: Finite non-negative method-specific importance scores.
98
+ depth: Requested leading-list protein budget.
99
+
100
+ Returns:
101
+ Fractional nomination that conserves the budget when support permits.
102
+
103
+ Raises:
104
+ ValueError: If identities, scores, or depth are invalid.
105
+ """
106
+ values = np.asarray(scores, dtype=np.float64)
107
+ integer(depth, "depth", minimum=1)
108
+ identities(feature_ids, "feature_ids")
109
+ if (
110
+ values.shape != (len(feature_ids),)
111
+ or not np.isfinite(values).all()
112
+ or np.any(values < 0.0)
113
+ or depth > len(feature_ids)
114
+ ):
115
+ raise ValueError("scores and feature identities are invalid")
116
+ positive = values > 0.0
117
+ support = tuple(feature for feature, keep in zip(feature_ids, positive, strict=True) if keep)
118
+ membership = np.zeros(values.shape, dtype=np.float64)
119
+ if len(support) <= depth:
120
+ membership[positive] = 1.0
121
+ return FractionalNomination(feature_ids, membership, depth, support)
122
+ ordered = np.sort(values[positive])[::-1]
123
+ boundary = float(ordered[depth - 1])
124
+ above = values > boundary
125
+ tied = values == boundary
126
+ membership[above] = 1.0
127
+ remaining = depth - int(np.count_nonzero(above))
128
+ membership[tied] = remaining / int(np.count_nonzero(tied))
129
+ return FractionalNomination(feature_ids, membership, depth, support)
130
+
131
+
132
+ def _membership(nomination: FractionalNomination) -> dict[str, float]:
133
+ return dict(zip(nomination.feature_ids, nomination.membership.tolist(), strict=True))
134
+
135
+
136
+ def _boundary_selection_probability(
137
+ membership_values: tuple[float, ...],
138
+ target_values: tuple[float, ...],
139
+ *,
140
+ require_all: bool,
141
+ ) -> float:
142
+ """Integrate nonlinear recovery over one uniform boundary tie."""
143
+ if require_all and any(value == 0.0 for value in target_values):
144
+ return 0.0
145
+ if not require_all and any(value == 1.0 for value in target_values):
146
+ return 1.0
147
+ fractional_values = tuple(value for value in membership_values if 0.0 < value < 1.0)
148
+ target_fractional = sum(0.0 < value < 1.0 for value in target_values)
149
+ if target_fractional == 0:
150
+ return 1.0 if require_all else 0.0
151
+ boundary_size = len(fractional_values)
152
+ boundary_slots = round(sum(fractional_values))
153
+ if require_all:
154
+ if boundary_slots < target_fractional:
155
+ return 0.0
156
+ return float(
157
+ comb(boundary_size - target_fractional, boundary_slots - target_fractional)
158
+ / comb(boundary_size, boundary_slots)
159
+ )
160
+ if boundary_size - target_fractional < boundary_slots:
161
+ return 1.0
162
+ return float(
163
+ 1.0
164
+ - comb(boundary_size - target_fractional, boundary_slots)
165
+ / comb(boundary_size, boundary_slots)
166
+ )
167
+
168
+
169
+ def evaluate_fractional_recovery(
170
+ nomination: FractionalNomination,
171
+ truth: MaterializedTruth,
172
+ *,
173
+ estimand: RecoveryEstimand,
174
+ ) -> FractionalRecovery:
175
+ """Evaluate one mechanism-specific recovery without a composite score.
176
+
177
+ Args:
178
+ nomination: Tie- and support-aware protein membership.
179
+ truth: Materialized generating mechanism on the same feature universe.
180
+ estimand: Exact, group, module, or interaction recovery definition.
181
+
182
+ Returns:
183
+ Bounded recovery with explicit truth and nomination support.
184
+
185
+ Raises:
186
+ ValueError: If the nomination and truth differ or the estimand has no target.
187
+ """
188
+ if nomination.feature_ids != truth.feature_universe:
189
+ raise ValueError("nomination and truth feature universes differ")
190
+ membership = _membership(nomination)
191
+ if estimand == "exact":
192
+ if not truth.direct_features:
193
+ raise ValueError("exact recovery requires direct truth features")
194
+ value = float(np.mean([membership[feature] for feature in truth.direct_features]))
195
+ truth_support = len(truth.direct_features)
196
+ elif estimand == "group":
197
+ if not truth.admissible_groups:
198
+ raise ValueError("group recovery requires admissible truth groups")
199
+ recovered = [
200
+ _boundary_selection_probability(
201
+ tuple(membership.values()),
202
+ tuple(membership[feature] for feature in group.features),
203
+ require_all=False,
204
+ )
205
+ for group in truth.admissible_groups
206
+ ]
207
+ value = float(np.mean(recovered))
208
+ truth_support = len(truth.admissible_groups)
209
+ elif estimand == "module":
210
+ weights = {item.feature: abs(item.weight) for item in truth.weights}
211
+ if not weights or sum(weights.values()) <= 0.0:
212
+ raise ValueError("module recovery requires non-zero truth weights")
213
+ value = sum(membership[feature] * weight for feature, weight in weights.items()) / sum(
214
+ weights.values()
215
+ )
216
+ truth_support = len(weights)
217
+ elif estimand == "interaction":
218
+ if len(truth.direct_features) != 2:
219
+ raise ValueError("interaction recovery requires two direct truth features")
220
+ value = _boundary_selection_probability(
221
+ tuple(membership.values()),
222
+ tuple(membership[feature] for feature in truth.direct_features),
223
+ require_all=True,
224
+ )
225
+ truth_support = 2
226
+ else:
227
+ raise ValueError(f"unsupported recovery estimand: {estimand}")
228
+ return FractionalRecovery(
229
+ estimand,
230
+ float(value),
231
+ truth_support,
232
+ len(nomination.positive_support),
233
+ nomination.requested_depth,
234
+ nomination.achieved_size,
235
+ )
236
+
237
+
238
+ def _state_count(weights: list[float], available: int, positive_support: int) -> int:
239
+ counts = Counter(weights)
240
+ selected = len(weights)
241
+ if selected > positive_support or positive_support > available:
242
+ raise ValueError("matched stratum support geometry is infeasible")
243
+ denominator = factorial(available - positive_support) * factorial(positive_support - selected)
244
+ for count in counts.values():
245
+ denominator *= factorial(count)
246
+ return factorial(available) // denominator
247
+
248
+
249
+ def _geometry(
250
+ nomination: FractionalNomination, strata: tuple[str, ...]
251
+ ) -> tuple[
252
+ dict[str, tuple[int, ...]],
253
+ dict[str, int],
254
+ dict[str, tuple[float, ...]],
255
+ ]:
256
+ if len(strata) != len(nomination.feature_ids) or any(not item for item in strata):
257
+ raise ValueError("strata must identify every feature")
258
+ available: dict[str, list[int]] = defaultdict(list)
259
+ support_counts: Counter[str] = Counter()
260
+ weights: dict[str, list[float]] = defaultdict(list)
261
+ positive = set(nomination.positive_support)
262
+ for index, (stratum, weight) in enumerate(zip(strata, nomination.membership, strict=True)):
263
+ available[stratum].append(index)
264
+ if nomination.feature_ids[index] in positive:
265
+ support_counts[stratum] += 1
266
+ if weight > 0.0:
267
+ weights[stratum].append(float(weight))
268
+ return (
269
+ {key: tuple(value) for key, value in available.items()},
270
+ {key: support_counts[key] for key in available},
271
+ {key: tuple(value) for key, value in weights.items()},
272
+ )
273
+
274
+
275
+ def _sample_nomination(
276
+ nomination: FractionalNomination,
277
+ available: dict[str, tuple[int, ...]],
278
+ support_counts: dict[str, int],
279
+ weights: dict[str, tuple[float, ...]],
280
+ rng: np.random.Generator,
281
+ ) -> FractionalNomination:
282
+ membership = np.zeros(len(nomination.feature_ids), dtype=np.float64)
283
+ positive_indices: list[int] = []
284
+ for key in sorted(available):
285
+ values = np.asarray(weights.get(key, ()), dtype=np.float64)
286
+ support_count = support_counts[key]
287
+ if support_count == 0:
288
+ continue
289
+ support = np.asarray(
290
+ rng.choice(available[key], size=support_count, replace=False),
291
+ dtype=np.int64,
292
+ )
293
+ positive_indices.extend(int(index) for index in support)
294
+ if values.size:
295
+ chosen = rng.choice(support, size=values.size, replace=False)
296
+ membership[chosen] = rng.permutation(values)
297
+ positive_set = set(positive_indices)
298
+ return FractionalNomination(
299
+ nomination.feature_ids,
300
+ membership,
301
+ nomination.requested_depth,
302
+ tuple(
303
+ feature for index, feature in enumerate(nomination.feature_ids) if index in positive_set
304
+ ),
305
+ )
306
+
307
+
308
+ def _expected_membership(
309
+ index: int,
310
+ *,
311
+ available: dict[str, tuple[int, ...]],
312
+ weights: dict[str, tuple[float, ...]],
313
+ ) -> float:
314
+ """Return marginal selection probability under the matched assignment law."""
315
+ for key, indices in available.items():
316
+ if index in indices:
317
+ return float(sum(weights.get(key, ())) / len(indices))
318
+ raise RuntimeError("feature index is absent from matched-reference strata")
319
+
320
+
321
+ def _no_target_selection_probability(
322
+ target_indices: tuple[int, ...],
323
+ *,
324
+ available: dict[str, tuple[int, ...]],
325
+ weights: dict[str, tuple[float, ...]],
326
+ ) -> float:
327
+ """Return the analytic probability that no target enters the final tied panel."""
328
+ targets = set(target_indices)
329
+ boundary_weights = tuple(
330
+ weight for values in weights.values() for weight in values if 0.0 < weight < 1.0
331
+ )
332
+ boundary_count = len(boundary_weights)
333
+ boundary_slots = round(sum(boundary_weights))
334
+ target_boundary_distribution = np.asarray([1.0], dtype=np.float64)
335
+ for key in sorted(available):
336
+ indices = available[key]
337
+ population = len(indices)
338
+ target_count = sum(index in targets for index in indices)
339
+ values = weights.get(key, ())
340
+ full_count = sum(weight == 1.0 for weight in values)
341
+ stratum_boundary_count = sum(0.0 < weight < 1.0 for weight in values)
342
+ no_full_probability = float(hypergeom.pmf(0, population, target_count, full_count))
343
+ maximum_target_boundary = min(
344
+ target_count,
345
+ stratum_boundary_count,
346
+ population - full_count,
347
+ )
348
+ boundary_targets = np.arange(maximum_target_boundary + 1, dtype=np.int64)
349
+ distribution = (
350
+ np.zeros(boundary_targets.size, dtype=np.float64)
351
+ if no_full_probability == 0.0
352
+ else no_full_probability
353
+ * np.asarray(
354
+ hypergeom.pmf(
355
+ boundary_targets,
356
+ population - full_count,
357
+ target_count,
358
+ stratum_boundary_count,
359
+ ),
360
+ dtype=np.float64,
361
+ )
362
+ )
363
+ target_boundary_distribution = np.convolve(
364
+ target_boundary_distribution,
365
+ distribution,
366
+ )
367
+ if boundary_slots == 0:
368
+ probability = float(target_boundary_distribution[0])
369
+ else:
370
+ target_counts = np.arange(target_boundary_distribution.size, dtype=np.int64)
371
+ avoid_boundary_targets = np.asarray(
372
+ hypergeom.pmf(0, boundary_count, target_counts, boundary_slots),
373
+ dtype=np.float64,
374
+ )
375
+ probability = float(target_boundary_distribution @ avoid_boundary_targets)
376
+ if not np.isfinite(probability) or probability < -1e-12 or probability > 1.0 + 1e-12:
377
+ raise RuntimeError("analytic matched-reference probability is invalid")
378
+ return float(np.clip(probability, 0.0, 1.0))
379
+
380
+
381
+ def _expected_matched_recovery(
382
+ truth: MaterializedTruth,
383
+ *,
384
+ estimand: RecoveryEstimand,
385
+ available: dict[str, tuple[int, ...]],
386
+ weights: dict[str, tuple[float, ...]],
387
+ positions: dict[str, int],
388
+ ) -> float:
389
+ """Calculate the matched-reference expectation without simulation error."""
390
+ if estimand == "exact":
391
+ expected = np.asarray(
392
+ [
393
+ _expected_membership(
394
+ positions[feature],
395
+ available=available,
396
+ weights=weights,
397
+ )
398
+ for feature in truth.direct_features
399
+ ],
400
+ dtype=np.float64,
401
+ )
402
+ return float(np.mean(expected))
403
+ if estimand == "module":
404
+ target_weights = np.asarray(
405
+ [abs(item.weight) for item in truth.weights],
406
+ dtype=np.float64,
407
+ )
408
+ expected = np.asarray(
409
+ [
410
+ _expected_membership(
411
+ positions[item.feature],
412
+ available=available,
413
+ weights=weights,
414
+ )
415
+ for item in truth.weights
416
+ ],
417
+ dtype=np.float64,
418
+ )
419
+ return float(np.average(expected, weights=target_weights))
420
+ if estimand == "group":
421
+ recovered = tuple(
422
+ 1.0
423
+ - _no_target_selection_probability(
424
+ tuple(positions[feature] for feature in group.features),
425
+ available=available,
426
+ weights=weights,
427
+ )
428
+ for group in truth.admissible_groups
429
+ )
430
+ return float(np.mean(recovered))
431
+ if estimand == "interaction":
432
+ first, second = (positions[feature] for feature in truth.direct_features)
433
+ neither_first = _no_target_selection_probability(
434
+ (first,),
435
+ available=available,
436
+ weights=weights,
437
+ )
438
+ neither_second = _no_target_selection_probability(
439
+ (second,),
440
+ available=available,
441
+ weights=weights,
442
+ )
443
+ neither = _no_target_selection_probability(
444
+ (first, second),
445
+ available=available,
446
+ weights=weights,
447
+ )
448
+ return float(np.clip(1.0 - neither_first - neither_second + neither, 0.0, 1.0))
449
+ raise ValueError(f"unsupported recovery estimand: {estimand}")
450
+
451
+
452
+ def sample_matched_nominations(
453
+ nomination: FractionalNomination,
454
+ *,
455
+ strata: tuple[str, ...],
456
+ draws: int,
457
+ seed: int,
458
+ ) -> tuple[FractionalNomination, ...]:
459
+ """Draw truth-blind nominations with the observed support geometry.
460
+
461
+ Args:
462
+ nomination: Observed support- and tie-aware nomination.
463
+ strata: Outcome-blind geometry stratum for every feature.
464
+ draws: Positive number of independently sampled nominations.
465
+ seed: Non-negative random seed.
466
+
467
+ Returns:
468
+ Matched nominations preserving positive-support size, membership
469
+ weights, realised cardinality, boundary ties, and stratum counts while
470
+ randomising identities over the complete eligible feature universe.
471
+
472
+ Raises:
473
+ ValueError: If geometry, draw count, or seed is invalid.
474
+ """
475
+ integer(draws, "draws", minimum=1)
476
+ integer(seed, "seed")
477
+ available, support_counts, weights = _geometry(nomination, strata)
478
+ if any(
479
+ len(weights.get(key, ())) > support_counts[key] or support_counts[key] > len(indices)
480
+ for key, indices in available.items()
481
+ ):
482
+ raise ValueError("matched nomination geometry is not feasible")
483
+ rng = np.random.default_rng(seed)
484
+ return tuple(
485
+ _sample_nomination(nomination, available, support_counts, weights, rng)
486
+ for _ in range(draws)
487
+ )
488
+
489
+
490
+ def evaluate_matched_recovery(
491
+ nomination: FractionalNomination,
492
+ truth: MaterializedTruth,
493
+ *,
494
+ estimand: RecoveryEstimand,
495
+ strata: tuple[str, ...],
496
+ ) -> MatchedRecovery:
497
+ """Calibrate observed recovery against truth-blind matched nominations.
498
+
499
+ Args:
500
+ nomination: Observed support- and tie-aware nomination.
501
+ truth: Materialized truth used only by the recovery evaluator.
502
+ estimand: Mechanism-specific recovery definition.
503
+ strata: Outcome-blind geometry stratum for every feature.
504
+
505
+ Returns:
506
+ Observed, expected, and chance-adjusted recovery under the analytic
507
+ matched assignment law.
508
+
509
+ Raises:
510
+ ValueError: If geometry or truth are invalid.
511
+ """
512
+ observed = evaluate_fractional_recovery(nomination, truth, estimand=estimand)
513
+ available, support_counts, weights = _geometry(nomination, strata)
514
+ positions = {feature: index for index, feature in enumerate(nomination.feature_ids)}
515
+ panel_count = 1
516
+ for key in sorted(available):
517
+ stratum_weights = list(weights.get(key, ()))
518
+ panel_count *= _state_count(
519
+ stratum_weights,
520
+ len(available[key]),
521
+ support_counts[key],
522
+ )
523
+ expected = _expected_matched_recovery(
524
+ truth,
525
+ estimand=estimand,
526
+ available=available,
527
+ weights=weights,
528
+ positions=positions,
529
+ )
530
+ return MatchedRecovery(
531
+ estimand,
532
+ "analytic",
533
+ nomination.requested_depth,
534
+ nomination.achieved_size,
535
+ observed.value,
536
+ expected,
537
+ observed.value - expected,
538
+ panel_count,
539
+ )
540
+
541
+
542
+ def _recovery_target_features(
543
+ truth: MaterializedTruth,
544
+ estimand: RecoveryEstimand,
545
+ ) -> frozenset[str]:
546
+ """Return every feature that can receive credit under one recovery estimand."""
547
+ if estimand in {"exact", "interaction"}:
548
+ features = truth.direct_features
549
+ elif estimand == "group":
550
+ features = tuple(feature for group in truth.admissible_groups for feature in group.features)
551
+ elif estimand == "module":
552
+ features = tuple(item.feature for item in truth.weights)
553
+ else: # pragma: no cover - exhaustive over RecoveryEstimand
554
+ raise ValueError(f"unsupported recovery estimand: {estimand}")
555
+ if not features:
556
+ raise ValueError("specificity requires a non-empty recovery target")
557
+ return frozenset(features)
558
+
559
+
560
+ def evaluate_outcome_specificity(
561
+ nomination: FractionalNomination,
562
+ own_truth: MaterializedTruth,
563
+ foreign_truths: tuple[MaterializedTruth, ...],
564
+ *,
565
+ estimand: RecoveryEstimand,
566
+ strata: tuple[str, ...],
567
+ ) -> OutcomeSpecificity:
568
+ """Compare own-truth recovery with recovery of disjoint foreign truths.
569
+
570
+ Args:
571
+ nomination: Outcome-derived, tie-aware nomination to evaluate unchanged.
572
+ own_truth: Generating truth for the outcome used to obtain the nomination.
573
+ foreign_truths: Other truth identities of the same mechanism and size.
574
+ estimand: Mechanism-specific exact, group, module, or interaction recovery.
575
+ strata: Outcome-blind matching stratum for every feature.
576
+
577
+ Returns:
578
+ Own adjusted recovery, mean foreign adjusted recovery, and their difference.
579
+
580
+ Raises:
581
+ ValueError: If truths differ in universe or mechanism, are absent, or overlap.
582
+ """
583
+ if not foreign_truths:
584
+ raise ValueError("specificity requires at least one foreign truth")
585
+ all_truths = (own_truth, *foreign_truths)
586
+ if any(
587
+ truth.kind != own_truth.kind or truth.feature_universe != own_truth.feature_universe
588
+ for truth in foreign_truths
589
+ ):
590
+ raise ValueError("specificity truths differ in mechanism or feature universe")
591
+ supports = tuple(_recovery_target_features(truth, estimand) for truth in all_truths)
592
+ target_sizes = (
593
+ {len(truth.admissible_groups) for truth in all_truths}
594
+ if estimand == "group"
595
+ else {len(support) for support in supports}
596
+ )
597
+ if len(target_sizes) != 1:
598
+ raise ValueError("specificity truths must have the same target size")
599
+ if any(left & right for index, left in enumerate(supports) for right in supports[index + 1 :]):
600
+ raise ValueError("specificity truth targets are not disjoint")
601
+ own = evaluate_matched_recovery(
602
+ nomination,
603
+ own_truth,
604
+ estimand=estimand,
605
+ strata=strata,
606
+ ).chance_adjusted_recovery
607
+ foreign = tuple(
608
+ evaluate_matched_recovery(
609
+ nomination,
610
+ truth,
611
+ estimand=estimand,
612
+ strata=strata,
613
+ ).chance_adjusted_recovery
614
+ for truth in foreign_truths
615
+ )
616
+ mean_foreign = float(np.mean(foreign))
617
+ return OutcomeSpecificity(
618
+ estimand,
619
+ own,
620
+ mean_foreign,
621
+ own - mean_foreign,
622
+ len(foreign),
623
+ )