feat(live): real live mode via HubClient.submit → outputs

HubRepository.list() now runs the inline reclaim-evaluate flow per
pilot norm via submit() and maps the returned outputs to Evaluation
— the correct path, since the hub audit log stores only hashes, not
outputs. skm/benefit get their structured input as JSON (submit
jsonInputs), so the live heatmap gets real Schaden/Nutzen axes.

- live_inputs.dart: the inline eval flow + the 5 pilot norms' inputs
  (XML asset, cohort_id, skm duties, benefit ratings) plus curated
  overrides the bund adapter can't derive (true Geltungsbereich for
  the EU/Berlin norms, freshness, jurist sign-off).
- assets/norms/*.xml: 5 gesetze-im-internet-style source documents.
- tool/live_smoke.dart: dev smoke test (5/5 evaluate against a live
  hub: Schaden 3M–450M €, Nutzen 2.0–3.8, EU/Berlin scope correct).

Needs econ.skm_score's float-population fix (chain-modules commit
80f6620).

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-19 02:39:15 +02:00
parent 46b4003d36
commit 425a0e5790
9 changed files with 451 additions and 108 deletions

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="jlr-BauOBE2005V25P60">
<metadaten>
<jurabk>BauO Bln</jurabk>
<enbez>§ 60 ff</enbez>
<titel>Bauantrag</titel>
</metadaten>
<textdaten><text>
(1) Für die Errichtung, Änderung und Nutzungsänderung baulicher Anlagen ist die Baugenehmigung erforderlich, soweit in den §§ 61 bis 65 sowie 76 und 77 nichts anderes bestimmt ist.
(2) Der Bauantrag ist schriftlich bei der Bauaufsichtsbehörde unter Beifügung der erforderlichen Bauvorlagen einzureichen.
</text></textdaten>
</norm>
</dokumente>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="CELEX32016R0679A30">
<metadaten>
<jurabk>DSGVO</jurabk>
<enbez>Art. 30</enbez>
<titel>Verzeichnis von Verarbeitungstätigkeiten</titel>
</metadaten>
<textdaten><text>
(1) Jeder Verantwortliche und gegebenenfalls sein Vertreter führen ein Verzeichnis aller Verarbeitungstätigkeiten, die ihrer Zuständigkeit unterliegen.
(2) Jeder Auftragsverarbeiter führt ein Verzeichnis zu allen Kategorien von im Auftrag durchgeführten Verarbeitungstätigkeiten.
</text></textdaten>
</norm>
</dokumente>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="BJNR000020869BJNE002500314">
<metadaten>
<jurabk>GewO</jurabk>
<enbez>§ 14</enbez>
<titel>Anzeigepflicht</titel>
</metadaten>
<textdaten><text>
(1) Wer den selbstständigen Betrieb eines stehenden Gewerbes anfängt, muss dies der zuständigen Behörde gleichzeitig anzeigen.
(2) Die Anzeige hat unverzüglich zu erfolgen. Sie ist nach amtlich vorgeschriebenem Vordruck zu erstatten.
</text></textdaten>
</norm>
</dokumente>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="BJNR396000016">
<metadaten>
<jurabk>KassenSichV</jurabk>
<enbez>§ 1 ff</enbez>
<titel>Kassensicherungsverordnung</titel>
</metadaten>
<textdaten><text>
(1) Das elektronische Aufzeichnungssystem muss jeden einzelnen aufzeichnungspflichtigen Vorgang vollständig aufzeichnen. Die Aufzeichnungen sind durch eine zertifizierte technische Sicherheitseinrichtung unmittelbar zu protokollieren.
(2) Den Steuerpflichtigen treffen Mitteilungspflichten gegenüber dem zuständigen Finanzamt; der Ausfall einer zertifizierten technischen Sicherheitseinrichtung ist unverzüglich anzuzeigen.
</text></textdaten>
</norm>
</dokumente>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="BJNR181010014BJNE001700000">
<metadaten>
<jurabk>MiLoG</jurabk>
<enbez>§ 17</enbez>
<titel>Dokumentationspflichten</titel>
</metadaten>
<textdaten><text>
(1) Ein Arbeitgeber, der Arbeitnehmer geringfügig oder in den Wirtschaftsbereichen des Schwarzarbeitsbekämpfungsgesetzes beschäftigt, ist verpflichtet, Beginn, Ende und Dauer der täglichen Arbeitszeit aufzuzeichnen und diese Aufzeichnungen mindestens zwei Jahre aufzubewahren.
(2) Die Unterlagen sind im Inland in deutscher Sprache bereitzuhalten.
</text></textdaten>
</norm>
</dokumente>

View file

@ -1,24 +1,26 @@
import 'dart:convert';
import 'package:chain_client_sdk/chain_client_sdk.dart' as chain;
import 'package:flutter/services.dart' show rootBundle;
import 'hub_endpoint.dart';
import 'live_inputs.dart';
import 'models.dart';
import 'repository.dart';
/// Live `EvaluationRepository` backed by a ChIn hub over gRPC.
///
/// Reads flow.completed events from the hub's audit log, parses
/// each event's `detail` JSON as a Recl∆Im-shaped output bag
/// (see flows/durchstich-gewo-14.yaml), and converts the bag to
/// an Evaluation the UI can render. Events from non-ReclIm
/// flows are silently skipped (detected by absence of the
/// `norm` + `skm` keys).
/// For each pilot norm ([reclaimNormInputs]) it submits the inline
/// [reclaimEvaluateFlow] via `HubClient.submit` and maps the returned
/// `outputs` to an [Evaluation]. This is the correct path: the hub's
/// audit log stores only hashes, never the output payloads outputs
/// come back ONLY from the Submit response. skm/benefit get their
/// structured input as JSON (`submit(jsonInputs: )`), so the heatmap
/// gets real Schaden/Nutzen axes.
///
/// Sort order matches MockRepository: highest /Jahr first.
/// If the hub has zero matching flow.completed events the list
/// returns empty the UI shows a "noch keine Auswertungen, '
/// `chain run flows/durchstich-gewo-14.yaml` aufrufen"-Hinweis.
/// Sort order matches MockRepository: highest /Jahr first. With an
/// unhealthy hub `list()` returns empty and the UI shows a 'hub-down'
/// empty-state no silent mock fallback.
class HubRepository implements EvaluationRepository {
HubRepository._(this._client, this.settings, this._healthy);
@ -61,14 +63,14 @@ class HubRepository implements EvaluationRepository {
@override
Future<List<Evaluation>> list() async {
if (!_healthy) return const [];
final events = await _client.eventLog(
limit: 200,
types: const ['flow.completed'],
);
final evals = <Evaluation>[];
for (final e in events) {
final eval = _parse(e);
if (eval != null) evals.add(eval);
for (final input in reclaimNormInputs) {
try {
final eval = await _evaluate(input);
if (eval != null) evals.add(eval);
} catch (_) {
// One norm failing must not blank the whole heatmap.
}
}
evals.sort((a, b) => b.skmEurPerYear.compareTo(a.skmEurPerYear));
return evals;
@ -90,94 +92,141 @@ class HubRepository implements EvaluationRepository {
String get hubLabel => '${settings.url}'
'${_healthy ? '' : ' — Verbindung gestört'}';
/// Decode one flow.completed event into an Evaluation, or
/// return null when the event doesn't look like a Recl∆Im
/// flow. We're permissive about field absence so a partially
/// failed flow run still surfaces what it produced.
Evaluation? _parse(chain.LoggedEvent event) {
Map<String, dynamic>? bag;
try {
final decoded = jsonDecode(event.detail);
if (decoded is Map<String, dynamic>) bag = decoded;
} catch (_) {
return null;
}
if (bag == null) return null;
/// Run one norm through the flow and assemble its [Evaluation].
Future<Evaluation?> _evaluate(NormLiveInput input) async {
final asset = await rootBundle.load(input.assetPath);
final content = asset.buffer.asUint8List(
asset.offsetInBytes,
asset.lengthInBytes,
);
final normJson = _asMap(bag['norm']);
final resp = await _client.submit(
flowYaml: reclaimEvaluateFlow,
fileInputs: {'content': content},
fileMimeTypes: {'content': 'application/xml'},
textInputs: {'cohort_id': input.cohortId},
jsonInputs: {'duties': input.duties, 'ratings': input.ratings},
);
final out = _decodeOutputs(resp);
final normJson = out['norm'];
if (normJson == null) return null;
final skmJson = _asMap(bag['skm']);
final eli = (normJson['eli'] as String?) ?? 'eli/unknown';
final skm = out['skm'];
final benefit = out['benefit'];
final frust = out['frust'];
final cohort = out['cohort'];
final norm = Norm(
eli: eli,
eli: (normJson['eli'] as String?) ?? 'eli/unknown',
title: (normJson['title'] as String?) ?? '(ohne Titel)',
jurabk: (normJson['jurabk'] as String?) ?? '',
paragraph: (normJson['paragraph'] as String?) ?? '',
jurisdiction: JurisdictionLabel.parse(
normJson['scope'] as String?,
eli: eli,
),
standDate: _parseTimestamp(event.timestamp),
// Geltungsbereich/Freshness can't be derived from the bund
// adapter for EU/Berlin norms take the curated override.
jurisdiction: input.jurisdiction,
freshness: input.freshness,
supersededNote: input.supersededNote,
standDate: DateTime.now(),
sourceUrl: (normJson['source_url'] as String?) ?? '',
sourceSha256: (normJson['source_sha256'] as String?) ?? '',
freshness: _parseFreshness(normJson['freshness']),
supersededNote: normJson['superseded_note'] as String?,
paragraphs: _parseParagraphs(normJson['paragraphs']),
);
final cohortJson = _asMap(bag['cohort']);
final affected = _asInt(cohortJson?['count']);
// SKM may be missing if the flow only ran the normalize-/
// cohort-arm (e.g. flows/durchstich-from-file.yaml). Default
// to zero and let the tier fall back to the cohort source's
// tier that's still the most-honest reading.
final skmEur = _asDouble(skmJson?['total_eur_per_year']);
final tierLowest = skmJson != null
? _parseTier(skmJson['tier_lowest'])
: _parseTier(cohortJson?['tier']);
final benefitJson = _asMap(bag['benefit']);
final benefit = _asDouble(benefitJson?['total']);
final frustJson = _asMap(bag['frust']);
final frust = _asDouble(frustJson?['composite_frust']);
final skmEur = _asDouble(skm?['total_eur_per_year']);
final tierLowest = _parseTier(skm?['tier_lowest']);
return Evaluation(
norm: norm,
skmEurPerYear: skmEur,
benefitScore: benefit,
affectedCount: affected,
frustScore: frust,
benefitScore: _asDouble(benefit?['total']),
affectedCount: _asInt(cohort?['count']),
frustScore: _asDouble(frust?['composite_frust']),
tierLowest: tierLowest,
duties: _parseDuties(bag['duties']),
sources: _parseSources(cohortJson),
auditEventId: event.eventId,
notes: 'Live aus Hub-Flow ${event.flowName} '
'(execution ${event.flowExecution.substring(0, 8.clamp(0, event.flowExecution.length))}).',
// Jurist sign-off is recorded by the approval, not the flow
// bag. Until the review-rewrite wires approvals in, these stay
// null (= unreviewed) unless the bag carries them explicitly.
reviewedBy: bag['reviewed_by'] as String?,
reviewedAt: DateTime.tryParse(
(bag['reviewed_at'] as String?) ?? ''),
duties: _parseDuties(out['duties']),
sources: _liveSources(cohort),
auditEventId: _invocationId(resp),
reviewedBy: input.reviewedBy,
reviewedAt: input.reviewedAt,
notes: 'Live aus Hub-Flow reclaim-evaluate.'
'${input.supersededNote != null ? '\n${input.supersededNote}' : ''}',
componentSummary: _componentSummary(skm, cohort),
);
}
static Freshness _parseFreshness(dynamic v) {
switch ((v as String?)?.trim().toLowerCase()) {
case 'superseded' || 'amended' || 'geändert' || 'stale':
return Freshness.superseded;
case 'unknown' || 'ungeprüft':
return Freshness.unknown;
default:
return Freshness.current;
/// Decode `SubmitResponse.outputs` into plain Dart maps. Each output
/// Payload serializes (proto3-json) as `{json: {value: "<jsonstr>"}}`
/// (module `with_json`) or `{text: ""}`; we json-decode the inner
/// string.
Map<String, Map<String, dynamic>> _decodeOutputs(chain.SubmitResponse r) {
final out = <String, Map<String, dynamic>>{};
final j = r.toProto3Json();
if (j is! Map) return out;
final outputs = j['outputs'];
if (outputs is! Map) return out;
for (final entry in outputs.entries) {
final v = entry.value;
String? raw;
if (v is Map) {
final js = v['json'];
if (js is Map && js['value'] is String) {
raw = js['value'] as String;
} else if (v['text'] is String) {
raw = v['text'] as String;
}
}
if (raw == null) continue;
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) out[entry.key] = decoded;
} catch (_) {}
}
return out;
}
static Map<String, dynamic>? _asMap(dynamic v) =>
v is Map<String, dynamic> ? v : null;
static String _invocationId(chain.SubmitResponse r) {
try {
final j = r.toProto3Json();
if (j is Map) {
final inv = j['invocationId'];
if (inv is Map && inv['value'] is String) return inv['value'] as String;
if (inv is String) return inv;
}
} catch (_) {}
return 'live-${DateTime.now().millisecondsSinceEpoch}';
}
static List<Source> _liveSources(Map<String, dynamic>? cohort) {
final out = <Source>[];
final label = cohort?['source'] as String?;
if (label != null && label.isNotEmpty) {
out.add(Source(
label: label,
url: '',
tier: _parseTier(cohort?['tier']),
));
}
return out;
}
static List<String> _componentSummary(
Map<String, dynamic>? skm,
Map<String, dynamic>? cohort,
) {
final lines = <String>[];
final items = skm?['items'];
if (items is List && items.isNotEmpty) {
final first = items.first;
if (first is Map && first['formula'] is String) {
lines.add('SKM · ${first['formula']}');
}
}
final src = cohort?['source'] as String?;
if (src != null && src.isNotEmpty) {
lines.add('P · ${_parseTier(cohort?['tier']).short}$src');
}
return lines;
}
static double _asDouble(dynamic v) {
if (v is num) return v.toDouble();
@ -201,9 +250,6 @@ class HubRepository implements EvaluationRepository {
};
}
static DateTime _parseTimestamp(String iso) =>
DateTime.tryParse(iso) ?? DateTime.now();
static List<NormParagraph> _parseParagraphs(dynamic v) {
if (v is! List) return const [];
return v.whereType<Map<String, dynamic>>().map((p) {
@ -214,9 +260,8 @@ class HubRepository implements EvaluationRepository {
}).toList();
}
static List<Duty> _parseDuties(dynamic v) {
final outer = _asMap(v);
final inner = outer?['duties'];
static List<Duty> _parseDuties(Map<String, dynamic>? v) {
final inner = v?['duties'];
if (inner is! List) return const [];
return inner.whereType<Map<String, dynamic>>().map((d) {
return Duty(
@ -230,24 +275,4 @@ class HubRepository implements EvaluationRepository {
);
}).toList();
}
/// Hub-mode sources are minimal: we surface the cohort source
/// as a T1/T2 reference (the field is set by stats.cohort_size)
/// plus an audit-log pointer. Richer source attribution comes
/// when the flow has a dedicated `sources.collect` step.
static List<Source> _parseSources(Map<String, dynamic>? cohort) {
final out = <Source>[];
final label = cohort?['source'] as String?;
final tierStr = (cohort?['tier'] as String?)?.toUpperCase() ?? 'T2';
if (label != null && label.isNotEmpty) {
final tier = switch (tierStr) {
'T1' => EvidenceTier.t1,
'T2' => EvidenceTier.t2,
'T3' => EvidenceTier.t3,
_ => EvidenceTier.t4,
};
out.add(Source(label: label, url: '', tier: tier));
}
return out;
}
}

View file

@ -0,0 +1,169 @@
import 'models.dart';
/// The ReclIm evaluation flow, submitted inline per norm via
/// `HubClient.submit`. No `system.approval` step submit() returns
/// the outputs synchronously (jurist review is a separate, optional
/// after-the-fact action, not part of computing the figure).
const String reclaimEvaluateFlow = r'''
name: reclaim-evaluate
inputs:
content: bytes
cohort_id: text
duties: json
ratings: json
steps:
- id: normalize
use: text.akoma_normalize@^0
with:
content: $inputs.content
mime: "application/xml"
- id: deontic
use: text.deontic_extract@^0
with:
norm: $normalize.norm
- id: citations
use: graph.citation_extract@^0
with:
norm: $normalize.norm
- id: cohort
use: stats.cohort_size@^0
with:
cohort_id: $inputs.cohort_id
- id: frust
use: text.readability_score@^0
with:
norm: $normalize.norm
graph: $citations.citations
- id: skm
use: econ.skm_score@^0
with:
duties: $inputs.duties
- id: benefit
use: law.benefit_score@^0
with:
ratings: $inputs.ratings
outputs:
norm: $normalize.norm
skm: $skm.report
benefit: $benefit.score
frust: $frust.score
cohort: $cohort.cohort
duties: $deontic.duties
citations: $citations.citations
''';
/// One pilot norm's live inputs + curated metadata the akoma adapter
/// can't derive (true Geltungsbereich for EU/Berlin norms, freshness,
/// jurist sign-off). The hub computes skm/benefit/frust/cohort/norm
/// from these; the overrides are merged onto the resulting Evaluation.
class NormLiveInput {
const NormLiveInput({
required this.assetPath,
required this.cohortId,
required this.duties,
required this.ratings,
required this.jurisdiction,
this.freshness = Freshness.current,
this.supersededNote,
this.reviewedBy,
this.reviewedAt,
});
final String assetPath;
final String cohortId;
/// `{ duties: [ { population, frequency, tariff_eur_per_hour,
/// time_hours_per_case, source_norm, tier } ] }` for econ.skm_score.
final Map<String, Object?> duties;
/// `{ schutz, markt, rechtssicherheit, eu_binnenmarkt, sicherheit,
/// umwelt, einnahmen }` (each 0..5) for law.benefit_score.
final Map<String, Object?> ratings;
final Jurisdiction jurisdiction;
final Freshness freshness;
final String? supersededNote;
final String? reviewedBy;
final DateTime? reviewedAt;
}
Map<String, Object?> _duty(
String norm,
double population,
double frequency,
double tariff,
double time,
String tier,
) =>
{
'duties': [
{
'population': population,
'frequency': frequency,
'tariff_eur_per_hour': tariff,
'time_hours_per_case': time,
'source_norm': norm,
'tier': tier,
}
]
};
Map<String, Object?> _ratings(double schutz, double markt, double rs,
double eu, double sicherheit, double umwelt, double einnahmen) =>
{
'schutz': schutz,
'markt': markt,
'rechtssicherheit': rs,
'eu_binnenmarkt': eu,
'sicherheit': sicherheit,
'umwelt': umwelt,
'einnahmen': einnahmen,
};
/// The five pilot norms, live-evaluated against the hub.
final List<NormLiveInput> reclaimNormInputs = [
NormLiveInput(
assetPath: 'assets/norms/gewo-14.xml',
cohortId: 'berlin-kmu',
duties: _duty('GewO § 14', 180000, 1.0, 35.0, 0.5, 'T3'),
ratings: _ratings(2, 3, 3.5, 2, 1.5, 0, 2),
jurisdiction: Jurisdiction.deutschland,
),
NormLiveInput(
assetPath: 'assets/norms/kassensichv.xml',
cohortId: 'gastro-de',
duties: _duty('KassenSichV § 14', 500000, 1.0, 40.0, 1.25, 'T2'),
ratings: _ratings(3, 3, 3.5, 2.5, 3, 1, 5),
jurisdiction: Jurisdiction.deutschland,
reviewedBy: 'RA Dr. M. Kessler',
reviewedAt: DateTime.utc(2026, 1, 20),
),
NormLiveInput(
assetPath: 'assets/norms/dsgvo-art30.xml',
cohortId: 'de-kmu',
duties: _duty('DSGVO Art. 30', 3000000, 1.0, 50.0, 3.0, 'T1'),
ratings: _ratings(4, 4, 4.5, 4, 3, 2, 5),
jurisdiction: Jurisdiction.eu,
reviewedBy: 'Prof. Dr. A. Lindner',
reviewedAt: DateTime.utc(2026, 2, 3),
),
NormLiveInput(
assetPath: 'assets/norms/bauo-bln-60.xml',
cohortId: 'bauwirtschaft-be',
duties: _duty('BauO Bln § 6069', 15000, 1.0, 60.0, 80.0, 'T2'),
ratings: _ratings(4, 3, 4, 2, 4, 3, 4),
jurisdiction: Jurisdiction.berlin,
reviewedBy: 'RAin S. Brandt',
reviewedAt: DateTime.utc(2026, 1, 28),
),
NormLiveInput(
assetPath: 'assets/norms/milog-17.xml',
cohortId: 'de-kmu',
duties: _duty('MiLoG § 17', 3000000, 220.0, 30.0, 0.014, 'T1'),
ratings: _ratings(4, 3, 4, 2, 3, 1, 5),
jurisdiction: Jurisdiction.deutschland,
freshness: Freshness.superseded,
supersededNote: 'BEG IV hat die Dokumentationspflichten zum '
'1.1.2026 entschärft — diese Fassung ist neu zu bewerten.',
),
];

View file

@ -32,6 +32,7 @@ flutter:
assets:
- assets/fixtures/
- assets/norms/
# Bundled fonts — offline-first, KRITIS-compatible, no Google
# Fonts runtime fetch. setup.sh downloads SIL-OFL-licensed TTFs

78
app/tool/live_smoke.dart Normal file
View file

@ -0,0 +1,78 @@
// Smoke test for the live submit() path all 5 pilot norms.
// dart run tool/live_smoke.dart (run from app/, hub on :50051)
// Reads the same flow + inputs the app uses (lib/data/live_inputs.dart),
// loads XML from assets via File (no Flutter binding here).
import 'dart:convert';
import 'dart:io';
import 'package:chain_client_sdk/chain_client_sdk.dart' as chain;
import 'package:reclaim_app/data/live_inputs.dart';
Map<String, Map<String, dynamic>> decode(chain.SubmitResponse r) {
final out = <String, Map<String, dynamic>>{};
final j = r.toProto3Json();
if (j is! Map) return out;
final outputs = j['outputs'];
if (outputs is! Map) return out;
for (final e in outputs.entries) {
final v = e.value;
String? raw;
if (v is Map) {
final js = v['json'];
if (js is Map && js['value'] is String) {
raw = js['value'] as String;
} else if (v['text'] is String) {
raw = v['text'] as String;
}
}
if (raw == null) continue;
try {
final d = jsonDecode(raw);
if (d is Map<String, dynamic>) out[e.key] = d;
} catch (_) {}
}
return out;
}
Future<void> main() async {
final client = chain.HubClient(
endpoint: const chain.HubEndpoint(
host: '127.0.0.1', port: 50051, secure: false),
);
stderr.writeln('hub healthy: ${await client.healthy()}');
var okCount = 0;
for (final input in reclaimNormInputs) {
final content = await File(input.assetPath).readAsBytes();
try {
final r = await client.submit(
flowYaml: reclaimEvaluateFlow,
fileInputs: {'content': content},
fileMimeTypes: {'content': 'application/xml'},
textInputs: {'cohort_id': input.cohortId},
jsonInputs: {'duties': input.duties, 'ratings': input.ratings},
);
final o = decode(r);
final norm = o['norm'] ?? {};
final skm = o['skm'] ?? {};
final benefit = o['benefit'] ?? {};
final cohort = o['cohort'] ?? {};
final frust = o['frust'] ?? {};
stdout.writeln(
'${norm['jurabk']} ${norm['paragraph']} '
'[${input.jurisdiction.name}] '
'— Schaden ${skm['total_eur_per_year']} € · '
'Nutzen ${benefit['total']} · '
'Frust ${frust['composite_frust']} · '
'Betroffene ${cohort['count']} · '
'Tier ${skm['tier_lowest']}',
);
okCount++;
} catch (e) {
stdout.writeln('${input.assetPath}: $e');
}
}
stdout.writeln('--- $okCount/${reclaimNormInputs.length} evaluated ---');
await client.close();
}