ioaudit 0.1.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.
ioaudit/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """ioaudit: transparent diagnostics for input-output tables."""
2
+
3
+ from .audit import audit
4
+ from .conventions import AccountingConvention
5
+ from .exceptions import IOAuditError, IONumericalError, IOValidationError
6
+ from .file_diagnostics import DelimitedFileReport, inspect_csv, inspect_delimited
7
+ from .model import IOSystem, TradeFlows
8
+ from .results import AuditReport
9
+ from ._version import __version__
10
+
11
+ __all__ = [
12
+ "IOSystem",
13
+ "TradeFlows",
14
+ "AccountingConvention",
15
+ "AuditReport",
16
+ "audit",
17
+ "DelimitedFileReport",
18
+ "inspect_csv",
19
+ "inspect_delimited",
20
+ "IOAuditError",
21
+ "IOValidationError",
22
+ "IONumericalError",
23
+ ]
@@ -0,0 +1,494 @@
1
+ """Compile declared IO accounting semantics into reusable calculation plans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from ._balance_core import (
11
+ inflow_adjustment,
12
+ normalize_tolerance,
13
+ outflow_adjustment,
14
+ output_adjustment_vector,
15
+ resolve_input_adjustment,
16
+ trade_side,
17
+ vector,
18
+ )
19
+ from .conventions import AccountingConvention
20
+ from .structure import _all_finite, _core_inputs_are_safe, _shape_of
21
+
22
+
23
+ @dataclass
24
+ class BalanceSidePlan:
25
+ """One compiled accounting identity."""
26
+
27
+ available: bool = False
28
+ axis: int = 0
29
+ offset: np.ndarray | None = None
30
+ equation: str = ""
31
+ reason: str | None = None
32
+ uses: frozenset[str] = frozenset()
33
+
34
+
35
+ @dataclass
36
+ class AccountingPlan:
37
+ """Validated inputs and equations shared by all residual diagnostics."""
38
+
39
+ y: np.ndarray | None = None
40
+ v: np.ndarray | None = None
41
+ output_adjustment: np.ndarray | None = None
42
+ output: BalanceSidePlan = field(
43
+ default_factory=lambda: BalanceSidePlan(axis=1)
44
+ )
45
+ input: BalanceSidePlan = field(
46
+ default_factory=lambda: BalanceSidePlan(axis=0)
47
+ )
48
+ tolerance: dict[str, float] | None = None
49
+ notes: list[str] = field(default_factory=list)
50
+
51
+
52
+ def _has_component_risk(components: Any, field_name: str) -> bool:
53
+ if components is None:
54
+ return False
55
+ for collection_name in ("double_count_risk", "component_label_risks"):
56
+ for item in getattr(components, collection_name, []) or []:
57
+ if item.get("field") == field_name:
58
+ return True
59
+ return False
60
+
61
+
62
+ def _sum_vectors(*values: np.ndarray) -> np.ndarray:
63
+ """Add accounting offsets while converting overflow into a finite-check result."""
64
+
65
+ with np.errstate(over="ignore", invalid="ignore"):
66
+ result = np.asarray(values[0], dtype=float).copy()
67
+ for value in values[1:]:
68
+ result = result + np.asarray(value, dtype=float)
69
+ return result
70
+
71
+
72
+ def _validated_component(
73
+ value: Any,
74
+ *,
75
+ field_name: str,
76
+ n: int,
77
+ sectors: list[Any],
78
+ components: Any,
79
+ ) -> tuple[np.ndarray | None, str | None]:
80
+ if _has_component_risk(components, field_name):
81
+ return (
82
+ None,
83
+ f"{field_name} contains an ambiguous subtotal or component label",
84
+ )
85
+ return vector(value, expected=field_name, n=n, sectors=sectors)
86
+
87
+
88
+ _OUTPUT_TRADE_LABELS = frozenset(
89
+ {
90
+ "imports",
91
+ "international imports",
92
+ "imports of goods and services",
93
+ "interregional inflows",
94
+ "combined inflows",
95
+ "移入",
96
+ "輸入",
97
+ "移輸入",
98
+ "exports",
99
+ "international exports",
100
+ "exports of goods and services",
101
+ "interregional outflows",
102
+ "combined outflows",
103
+ "移出",
104
+ "輸出",
105
+ "移輸出",
106
+ }
107
+ )
108
+
109
+
110
+ def _output_adjustment_component_labels(value: Any) -> list[Any] | None:
111
+ """Return semantic component labels for a two-dimensional adjustment block."""
112
+
113
+ if value is None:
114
+ return None
115
+ columns = getattr(value, "columns", None)
116
+ if columns is None:
117
+ return None
118
+ labels = list(columns)
119
+ return labels if labels and all(isinstance(label, str) for label in labels) else None
120
+
121
+
122
+ def _output_adjustment_trade_conflict(io: Any, value: Any) -> str | None:
123
+ """Reject ambiguous overlap between output adjustments and trade flows."""
124
+
125
+ trade = getattr(io, "trade", None)
126
+ if trade is None or not trade.has_any or value is None:
127
+ return None
128
+ labels = _output_adjustment_component_labels(value)
129
+ if labels is None:
130
+ return (
131
+ "output_adjustments were supplied with TradeFlows but their "
132
+ "component labels are unavailable; possible trade double-counting "
133
+ "cannot be ruled out"
134
+ )
135
+ from .structure import _normalize_label
136
+
137
+ normalized = {_normalize_label(label) for label in labels}
138
+ overlaps = sorted(normalized & _OUTPUT_TRADE_LABELS)
139
+ if overlaps:
140
+ return (
141
+ "output_adjustments overlap with declared trade component(s): "
142
+ + ", ".join(overlaps)
143
+ )
144
+ return None
145
+
146
+
147
+ def _append_output_adjustment(
148
+ side: BalanceSidePlan,
149
+ adjustment: np.ndarray | None,
150
+ ) -> BalanceSidePlan:
151
+ """Add a validated signed output adjustment to an available identity."""
152
+
153
+ if adjustment is None or not side.available:
154
+ return side
155
+ side.offset = _sum_vectors(side.offset, adjustment)
156
+ side.uses = frozenset((*side.uses, "output_adjustments"))
157
+ side.equation = f"{side.equation} + output_adjustments (signed)"
158
+ return side
159
+
160
+
161
+ def _resolve_output(
162
+ io: Any,
163
+ *,
164
+ y: np.ndarray | None,
165
+ y_reason: str | None,
166
+ convention: AccountingConvention,
167
+ n: int,
168
+ sectors: list[Any],
169
+ plan: AccountingPlan,
170
+ output_adjustment: np.ndarray | None,
171
+ output_adjustment_reason: str | None,
172
+ ) -> BalanceSidePlan:
173
+ side = BalanceSidePlan(axis=1)
174
+ if y is None:
175
+ side.reason = y_reason or "Y is unavailable"
176
+ return side
177
+
178
+ output_representation = convention.output_representation
179
+ raw_output_adjustment = getattr(io, "output_adjustments", None)
180
+ if output_representation == "unknown":
181
+ side.reason = "output_representation='unknown'; output-side adjustments are not declared"
182
+ return side
183
+ if output_representation == "complete" and raw_output_adjustment is not None:
184
+ side.reason = (
185
+ output_adjustment_reason
186
+ or "output_adjustments were supplied but output_representation='complete'"
187
+ )
188
+ return side
189
+ if output_representation == "adjustments_required":
190
+ if raw_output_adjustment is None:
191
+ side.reason = "output_adjustments are required but were not supplied"
192
+ return side
193
+ if output_adjustment is None:
194
+ side.reason = output_adjustment_reason or "output_adjustments are unavailable"
195
+ return side
196
+
197
+ representation = convention.trade_representation
198
+ if convention.transaction_scope == "total":
199
+ # ``unknown`` means that no conflicting declaration was made. An
200
+ # explicit separate/outflows-in-Y declaration conflicts with a total
201
+ # transaction table and is therefore kept auditable as SKIPPED.
202
+ if representation in {"separate", "outflows_in_Y"}:
203
+ side.reason = (
204
+ "transaction_scope='total' is incompatible with the declared "
205
+ f"trade_representation={representation!r}"
206
+ )
207
+ side.equation = (
208
+ "SKIPPED because transaction_scope='total' does not "
209
+ "support explicitly declared "
210
+ f"trade_representation={representation!r}"
211
+ )
212
+ return side
213
+ side.available = True
214
+ side.offset = y
215
+ side.equation = "x = row_sum(Z) + row_sum(Y)"
216
+ side.uses = frozenset({"Y"})
217
+ plan.notes.append("inflow_sign not applicable")
218
+ if getattr(io, "trade", None) is not None and io.trade.has_any:
219
+ plan.notes.append(
220
+ "trade flows were supplied but excluded by the total transaction scope"
221
+ )
222
+ return _append_output_adjustment(side, output_adjustment)
223
+
224
+ if convention.transaction_scope == "unknown":
225
+ side.reason = "transaction_scope is unknown"
226
+ return side
227
+ if convention.import_treatment == "unknown":
228
+ side.reason = "import_treatment is unknown"
229
+ return side
230
+ if representation == "unknown":
231
+ side.reason = "trade_representation is unknown"
232
+ return side
233
+ if convention.import_treatment == "none" and representation != "embedded":
234
+ side.reason = (
235
+ "import_treatment='none' is incompatible with the declared "
236
+ f"trade_representation={representation!r}"
237
+ )
238
+ side.equation = (
239
+ "SKIPPED because import_treatment='none' does not support "
240
+ "explicitly declared "
241
+ f"trade_representation={representation!r}"
242
+ )
243
+ return side
244
+ if convention.import_treatment == "none":
245
+ side.available = True
246
+ side.offset = y
247
+ side.equation = "x = row_sum(Z) + row_sum(Y)"
248
+ side.uses = frozenset({"Y"})
249
+ plan.notes.append("inflow_sign not applicable")
250
+ plan.notes.append("outflow_sign not applicable")
251
+ if getattr(io, "trade", None) is not None and io.trade.has_any:
252
+ plan.notes.append(
253
+ "trade flows were supplied but excluded because import_treatment='none'"
254
+ )
255
+ return _append_output_adjustment(side, output_adjustment)
256
+ if representation == "embedded":
257
+ side.available = True
258
+ side.offset = y
259
+ side.equation = "x = row_sum(Z) + row_sum(Y) (trade embedded in Y)"
260
+ side.uses = frozenset({"Y"})
261
+ plan.notes.append("inflow_sign not applicable")
262
+ plan.notes.append("outflow_sign not applicable")
263
+ if getattr(io, "trade", None) is not None and io.trade.has_any:
264
+ plan.notes.append(
265
+ "trade flows were supplied but excluded because trade_representation='embedded'"
266
+ )
267
+ return _append_output_adjustment(side, output_adjustment)
268
+
269
+ trade = getattr(io, "trade", None)
270
+ inflows, reason, inflow_label = trade_side(
271
+ trade,
272
+ side="inflows",
273
+ scope=convention.external_flow_scope,
274
+ n=n,
275
+ sectors=sectors,
276
+ )
277
+ if inflows is None:
278
+ side.reason = reason
279
+ return side
280
+ signed_inflow = inflow_adjustment(inflows, convention.inflow_sign)
281
+ if signed_inflow is None:
282
+ side.reason = "inflow_sign='unknown'; no sign inference is performed"
283
+ return side
284
+
285
+ if representation == "outflows_in_Y":
286
+ side.available = True
287
+ side.offset = _sum_vectors(y, signed_inflow)
288
+ side.uses = frozenset({"Y", "inflows"})
289
+ side.equation = (
290
+ "x = row_sum(Z) + row_sum(Y) + inflow (signed)"
291
+ if convention.inflow_sign == "negative"
292
+ else "x = row_sum(Z) + row_sum(Y) - inflow (positive magnitude)"
293
+ )
294
+ plan.notes.append("outflow_sign not applicable")
295
+ return _append_output_adjustment(side, output_adjustment)
296
+ if representation != "separate":
297
+ side.reason = f"unsupported trade_representation={representation!r}"
298
+ return side
299
+
300
+ outflows, reason, outflow_label = trade_side(
301
+ trade,
302
+ side="outflows",
303
+ scope=convention.external_flow_scope,
304
+ n=n,
305
+ sectors=sectors,
306
+ )
307
+ if outflows is None:
308
+ side.reason = reason
309
+ return side
310
+ signed_outflow = outflow_adjustment(outflows, convention.outflow_sign)
311
+ if signed_outflow is None:
312
+ side.reason = "outflow_sign='unknown'; no sign inference is performed"
313
+ return side
314
+
315
+ side.available = True
316
+ side.offset = _sum_vectors(y, signed_inflow, signed_outflow)
317
+ side.uses = frozenset({"Y", "inflows", "outflows"})
318
+ inflow_term = (
319
+ f"- inflow ({inflow_label}, positive magnitude)"
320
+ if convention.inflow_sign == "positive"
321
+ else f"+ inflow ({inflow_label}, negative signed)"
322
+ )
323
+ outflow_term = (
324
+ f"+ outflow ({outflow_label}, positive magnitude)"
325
+ if convention.outflow_sign == "positive"
326
+ else f"- outflow ({outflow_label}, negative signed)"
327
+ )
328
+ side.equation = (
329
+ f"x = row_sum(Z) + row_sum(Y) {outflow_term} {inflow_term}"
330
+ )
331
+ return _append_output_adjustment(side, output_adjustment)
332
+
333
+
334
+ def _resolve_input(
335
+ io: Any,
336
+ *,
337
+ v: np.ndarray | None,
338
+ v_reason: str | None,
339
+ convention: AccountingConvention,
340
+ n: int,
341
+ sectors: list[Any],
342
+ plan: AccountingPlan,
343
+ ) -> BalanceSidePlan:
344
+ side = BalanceSidePlan(axis=0)
345
+ if v is None:
346
+ side.reason = v_reason or "V is unavailable"
347
+ return side
348
+ adjustment, reason, _source, note = resolve_input_adjustment(io, n=n)
349
+ if note:
350
+ plan.notes.append(note)
351
+
352
+ representation = convention.input_representation
353
+ if representation == "unknown":
354
+ side.reason = (
355
+ "input_representation='unknown'; the completeness of V and any "
356
+ "input-side adjustments is not declared"
357
+ )
358
+ return side
359
+ if representation == "complete":
360
+ if _source is not None:
361
+ side.reason = (
362
+ "input-side adjustment was supplied but input_representation='complete'; "
363
+ "the adjustment's relationship to V is ambiguous"
364
+ )
365
+ return side
366
+ side.available = True
367
+ side.offset = v
368
+ side.equation = "x = column_sum(Z) + column_sum(V)"
369
+ side.uses = frozenset({"V"})
370
+ return side
371
+ if representation == "adjustments_required":
372
+ if adjustment is None:
373
+ side.reason = reason or "input_adjustments are unavailable"
374
+ return side
375
+ side.available = True
376
+ side.offset = _sum_vectors(v, adjustment)
377
+ side.equation = "x = column_sum(Z) + column_sum(V) + input_adjustment"
378
+ side.uses = frozenset({"V", "input_adjustments"})
379
+ return side
380
+ side.reason = f"unsupported input_representation={representation!r}"
381
+ return side
382
+
383
+
384
+ def compile_accounting_plan(
385
+ io: Any,
386
+ *,
387
+ z: Any,
388
+ x: np.ndarray | None,
389
+ convention: AccountingConvention | None,
390
+ sectors: list[Any],
391
+ components: Any = None,
392
+ structure: Any = None,
393
+ tolerance: dict[str, float] | None = None,
394
+ ) -> AccountingPlan:
395
+ """Resolve all declared accounting inputs exactly once."""
396
+
397
+ plan = AccountingPlan(
398
+ tolerance=normalize_tolerance(tolerance),
399
+ )
400
+ z_shape = _shape_of(z) if z is not None else ()
401
+ if (
402
+ z is None
403
+ or x is None
404
+ or len(z_shape) != 2
405
+ or z_shape[0] != z_shape[1]
406
+ or getattr(x, "ndim", None) != 1
407
+ or len(x) != z_shape[0]
408
+ or not _all_finite(z)
409
+ or not _all_finite(x)
410
+ or (structure is not None and not _core_inputs_are_safe(structure))
411
+ ):
412
+ reason = "Z, x, or their sector labels are not safely aligned"
413
+ plan.output.reason = reason
414
+ plan.input.reason = reason
415
+ return plan
416
+
417
+ n = z_shape[0]
418
+ plan.y, y_reason = _validated_component(
419
+ getattr(io, "Y", None),
420
+ field_name="Y",
421
+ n=n,
422
+ sectors=sectors,
423
+ components=components,
424
+ )
425
+ plan.v, v_reason = _validated_component(
426
+ getattr(io, "V", None),
427
+ field_name="V",
428
+ n=n,
429
+ sectors=sectors,
430
+ components=components,
431
+ )
432
+ output_adjustment_raw = getattr(io, "output_adjustments", None)
433
+ if _has_component_risk(components, "output_adjustments"):
434
+ plan.output_adjustment = None
435
+ output_adjustment_reason = (
436
+ "output_adjustments contains an ambiguous subtotal or component label"
437
+ )
438
+ output_note = None
439
+ else:
440
+ plan.output_adjustment, output_adjustment_reason, output_note = output_adjustment_vector(
441
+ output_adjustment_raw,
442
+ name="output_adjustments",
443
+ n=n,
444
+ sectors=sectors,
445
+ )
446
+ if output_note:
447
+ plan.notes.append(output_note)
448
+ if output_adjustment_raw is not None:
449
+ conflict_reason = _output_adjustment_trade_conflict(
450
+ io, output_adjustment_raw
451
+ )
452
+ if conflict_reason:
453
+ plan.output_adjustment = None
454
+ output_adjustment_reason = conflict_reason
455
+ plan.notes.append(conflict_reason)
456
+ elif plan.output_adjustment is None and output_adjustment_reason:
457
+ plan.notes.append(output_adjustment_reason)
458
+ if y_reason and plan.y is None:
459
+ plan.notes.append(y_reason)
460
+ if v_reason and plan.v is None:
461
+ plan.notes.append(v_reason)
462
+ if convention is None:
463
+ reason = "AccountingConvention was not supplied"
464
+ plan.output.reason = reason
465
+ plan.input.reason = reason
466
+ return plan
467
+ plan.output = _resolve_output(
468
+ io,
469
+ y=plan.y,
470
+ y_reason=y_reason,
471
+ convention=convention,
472
+ n=n,
473
+ sectors=sectors,
474
+ plan=plan,
475
+ output_adjustment=plan.output_adjustment,
476
+ output_adjustment_reason=output_adjustment_reason,
477
+ )
478
+ plan.input = _resolve_input(
479
+ io,
480
+ v=plan.v,
481
+ v_reason=v_reason,
482
+ convention=convention,
483
+ n=n,
484
+ sectors=sectors,
485
+ plan=plan,
486
+ )
487
+ return plan
488
+
489
+
490
+ __all__ = [
491
+ "AccountingPlan",
492
+ "BalanceSidePlan",
493
+ "compile_accounting_plan",
494
+ ]