@tiangong-lca/cli 0.0.11 → 0.0.13

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 (60) hide show
  1. package/README.md +18 -7
  2. package/assets/tidas-schemas/tidas_contacts.json +312 -0
  3. package/assets/tidas-schemas/tidas_contacts_category.json +126 -0
  4. package/assets/tidas-schemas/tidas_data_types.json +359 -0
  5. package/assets/tidas-schemas/tidas_flowproperties.json +303 -0
  6. package/assets/tidas-schemas/tidas_flowproperties_category.json +61 -0
  7. package/assets/tidas-schemas/tidas_flows.json +809 -0
  8. package/assets/tidas-schemas/tidas_flows_elementary_category.json +720 -0
  9. package/assets/tidas-schemas/tidas_flows_product_category.json +59623 -0
  10. package/assets/tidas-schemas/tidas_lciamethods.json +1326 -0
  11. package/assets/tidas-schemas/tidas_lciamethods_category.json +685 -0
  12. package/assets/tidas-schemas/tidas_lifecyclemodels.json +1374 -0
  13. package/assets/tidas-schemas/tidas_locations_category.json +2593 -0
  14. package/assets/tidas-schemas/tidas_processes.json +1646 -0
  15. package/assets/tidas-schemas/tidas_processes_category.json +10795 -0
  16. package/assets/tidas-schemas/tidas_sources.json +228 -0
  17. package/assets/tidas-schemas/tidas_sources_category.json +100 -0
  18. package/assets/tidas-schemas/tidas_unitgroups.json +338 -0
  19. package/assets/tidas-schemas/tidas_unitgroups_category.json +61 -0
  20. package/dist/src/cli.js +847 -13
  21. package/dist/src/cli.js.map +1 -1
  22. package/dist/src/lib/dataset-classification.js +947 -0
  23. package/dist/src/lib/dataset-classification.js.map +1 -0
  24. package/dist/src/lib/dataset-command.js +11 -0
  25. package/dist/src/lib/dataset-command.js.map +1 -1
  26. package/dist/src/lib/dataset-contract.js +2 -0
  27. package/dist/src/lib/dataset-contract.js.map +1 -1
  28. package/dist/src/lib/dataset-curation-queue.js +359 -2
  29. package/dist/src/lib/dataset-curation-queue.js.map +1 -1
  30. package/dist/src/lib/dataset-import-lca.js +18 -0
  31. package/dist/src/lib/dataset-import-lca.js.map +1 -1
  32. package/dist/src/lib/dataset-local.js +94 -1
  33. package/dist/src/lib/dataset-local.js.map +1 -1
  34. package/dist/src/lib/dataset-maintenance-clear-account.js +506 -0
  35. package/dist/src/lib/dataset-maintenance-clear-account.js.map +1 -0
  36. package/dist/src/lib/dataset-patch.js +797 -0
  37. package/dist/src/lib/dataset-patch.js.map +1 -0
  38. package/dist/src/lib/dataset-remote-verify.js +188 -3
  39. package/dist/src/lib/dataset-remote-verify.js.map +1 -1
  40. package/dist/src/lib/dataset-save-draft-run.js +725 -0
  41. package/dist/src/lib/dataset-save-draft-run.js.map +1 -0
  42. package/dist/src/lib/dataset-validate.js +32 -2
  43. package/dist/src/lib/dataset-validate.js.map +1 -1
  44. package/dist/src/lib/flow-publish-version.js +7 -2
  45. package/dist/src/lib/flow-publish-version.js.map +1 -1
  46. package/dist/src/lib/flow-qa.js +8 -0
  47. package/dist/src/lib/flow-qa.js.map +1 -1
  48. package/dist/src/lib/identity-preflight.js +895 -117
  49. package/dist/src/lib/identity-preflight.js.map +1 -1
  50. package/dist/src/lib/lifecyclemodel-qa.js +159 -34
  51. package/dist/src/lib/lifecyclemodel-qa.js.map +1 -1
  52. package/dist/src/lib/process-required-fields.js +62 -12
  53. package/dist/src/lib/process-required-fields.js.map +1 -1
  54. package/dist/src/lib/process-save-draft-run.js +10 -0
  55. package/dist/src/lib/process-save-draft-run.js.map +1 -1
  56. package/dist/src/lib/process-save-draft.js +59 -3
  57. package/dist/src/lib/process-save-draft.js.map +1 -1
  58. package/dist/src/lib/supabase-client.js +19 -8
  59. package/dist/src/lib/supabase-client.js.map +1 -1
  60. package/package.json +2 -1
@@ -14,6 +14,7 @@ const SCHEMA_EXPORTS = {
14
14
  flow: 'FlowSchema',
15
15
  process: 'ProcessSchema',
16
16
  };
17
+ const REMOTE_DATA_SOURCES = new Set(['tg', 'co', 'my', 'te']);
17
18
  const ENTITY_FACTORY_EXPORTS = {
18
19
  flow: 'createFlow',
19
20
  process: 'createProcess',
@@ -76,12 +77,55 @@ function normalizePositiveInteger(value, label) {
76
77
  }
77
78
  return parsed;
78
79
  }
80
+ function normalizeNonNegativeNumber(value, label) {
81
+ if (value === undefined || value === null || value === '') {
82
+ return null;
83
+ }
84
+ const parsed = typeof value === 'number' ? value : Number(String(value));
85
+ if (!Number.isFinite(parsed) || parsed < 0) {
86
+ throw new CliError(`Expected ${label} to be a non-negative number.`, {
87
+ code: 'IDENTITY_PREFLIGHT_INVALID_REMOTE_SEARCH_OPTION',
88
+ exitCode: 2,
89
+ });
90
+ }
91
+ return parsed;
92
+ }
93
+ function normalizeMatchThreshold(value) {
94
+ const parsed = normalizeNonNegativeNumber(value, 'remote_candidate_search.match_threshold');
95
+ if (parsed === null) {
96
+ return null;
97
+ }
98
+ if (parsed > 1) {
99
+ throw new CliError('Expected remote_candidate_search.match_threshold to be between 0 and 1.', {
100
+ code: 'IDENTITY_PREFLIGHT_INVALID_REMOTE_SEARCH_OPTION',
101
+ exitCode: 2,
102
+ });
103
+ }
104
+ return parsed;
105
+ }
106
+ function emptyRemoteCandidateSearchConfig(enabled = false) {
107
+ return {
108
+ enabled,
109
+ query: null,
110
+ filter: null,
111
+ profileHints: null,
112
+ limit: null,
113
+ dataSource: null,
114
+ matchThreshold: null,
115
+ fullTextWeight: null,
116
+ extractedTextWeight: null,
117
+ semanticWeight: null,
118
+ rrfK: null,
119
+ pageSize: null,
120
+ pageCurrent: null,
121
+ };
122
+ }
79
123
  function normalizeRemoteCandidateSearch(value) {
80
124
  if (value === undefined || value === null) {
81
- return { enabled: false, query: null, filter: null, limit: null };
125
+ return emptyRemoteCandidateSearchConfig(false);
82
126
  }
83
127
  if (typeof value === 'boolean') {
84
- return { enabled: value, query: null, filter: null, limit: null };
128
+ return emptyRemoteCandidateSearchConfig(value);
85
129
  }
86
130
  if (!isRecord(value)) {
87
131
  throw new CliError('remote_candidate_search must be a boolean or object.', {
@@ -94,11 +138,31 @@ function normalizeRemoteCandidateSearch(value) {
94
138
  const filter = value.filter === undefined || value.filter === null
95
139
  ? null
96
140
  : normalizeToRecord(value.filter, 'remote_candidate_search.filter');
141
+ const profileHintsInput = value.profile_hints ?? value.profileHints ?? value.identity_profile_hints;
142
+ const profileHints = profileHintsInput === undefined || profileHintsInput === null
143
+ ? null
144
+ : normalizeToRecord(profileHintsInput, 'remote_candidate_search.profile_hints');
145
+ const dataSource = textValue(value.data_source) ?? textValue(value.dataSource) ?? textValue(value.source) ?? null;
146
+ if (dataSource && !REMOTE_DATA_SOURCES.has(dataSource)) {
147
+ throw new CliError('remote_candidate_search.data_source must be one of tg, co, my, or te.', {
148
+ code: 'IDENTITY_PREFLIGHT_INVALID_REMOTE_DATA_SOURCE',
149
+ exitCode: 2,
150
+ });
151
+ }
97
152
  return {
98
153
  enabled,
99
154
  query,
100
155
  filter,
156
+ profileHints,
101
157
  limit: normalizePositiveInteger(value.limit, 'remote_candidate_search.limit'),
158
+ dataSource,
159
+ matchThreshold: normalizeMatchThreshold(value.match_threshold ?? value.matchThreshold),
160
+ fullTextWeight: normalizeNonNegativeNumber(value.full_text_weight ?? value.fullTextWeight, 'remote_candidate_search.full_text_weight'),
161
+ extractedTextWeight: normalizeNonNegativeNumber(value.extracted_text_weight ?? value.extractedTextWeight, 'remote_candidate_search.extracted_text_weight'),
162
+ semanticWeight: normalizeNonNegativeNumber(value.semantic_weight ?? value.semanticWeight, 'remote_candidate_search.semantic_weight'),
163
+ rrfK: normalizePositiveInteger(value.rrf_k ?? value.rrfK, 'remote_candidate_search.rrf_k'),
164
+ pageSize: normalizePositiveInteger(value.page_size ?? value.pageSize, 'remote_candidate_search.page_size'),
165
+ pageCurrent: normalizePositiveInteger(value.page_current ?? value.pageCurrent, 'remote_candidate_search.page_current'),
102
166
  };
103
167
  }
104
168
  function pickKindTarget(input, kind) {
@@ -202,22 +266,130 @@ function readCandidateSource(candidatePath) {
202
266
  };
203
267
  }
204
268
  function mergeRemoteCandidateSearchConfig(inputConfig, options) {
269
+ const dataSource = options.remoteDataSource?.trim() || inputConfig.dataSource;
270
+ if (dataSource && !REMOTE_DATA_SOURCES.has(dataSource)) {
271
+ throw new CliError('--remote-data-source must be one of tg, co, my, or te.', {
272
+ code: 'IDENTITY_PREFLIGHT_INVALID_REMOTE_DATA_SOURCE',
273
+ exitCode: 2,
274
+ });
275
+ }
205
276
  return {
206
277
  enabled: options.remoteCandidateSearch ?? inputConfig.enabled,
207
278
  query: options.remoteQuery?.trim() || inputConfig.query,
208
279
  filter: options.remoteFilter ?? inputConfig.filter,
280
+ profileHints: inputConfig.profileHints,
209
281
  limit: options.remoteLimit ?? inputConfig.limit,
282
+ dataSource,
283
+ matchThreshold: inputConfig.matchThreshold,
284
+ fullTextWeight: inputConfig.fullTextWeight,
285
+ extractedTextWeight: inputConfig.extractedTextWeight,
286
+ semanticWeight: inputConfig.semanticWeight,
287
+ rrfK: inputConfig.rrfK,
288
+ pageSize: inputConfig.pageSize,
289
+ pageCurrent: inputConfig.pageCurrent,
210
290
  };
211
291
  }
212
292
  function remoteSearchEndpoint(kind) {
213
293
  return kind === 'process' ? 'process_hybrid_search' : 'flow_hybrid_search';
214
294
  }
295
+ function isRemoteQueryNoiseText(value) {
296
+ const text = value.normalize('NFKC').replace(/\s+/gu, ' ').trim();
297
+ if (!text) {
298
+ return true;
299
+ }
300
+ if (/^(not specified|not declared|unspecified|n\/a|none|null)$/iu.test(text)) {
301
+ return true;
302
+ }
303
+ if (/^not specified by the .* source\.?$/iu.test(text)) {
304
+ return true;
305
+ }
306
+ if (/^ilcd format$/iu.test(text)) {
307
+ return true;
308
+ }
309
+ if (/^ilcd data network\s*-\s*entry-level$/iu.test(text)) {
310
+ return true;
311
+ }
312
+ return false;
313
+ }
314
+ function queryFieldValues(value, limit = 4) {
315
+ return (Array.isArray(value) ? value : [value])
316
+ .filter((entry) => typeof entry === 'string')
317
+ .map((entry) => entry.trim())
318
+ .filter((entry) => !isRemoteQueryNoiseText(entry))
319
+ .slice(0, limit);
320
+ }
321
+ function appendQueryLine(lines, label, value, limit = 4) {
322
+ const values = queryFieldValues(value, limit);
323
+ if (values.length === 0) {
324
+ return;
325
+ }
326
+ lines.push(`${label}: ${values.join('; ')}`);
327
+ }
328
+ function exchangeFlowRefsFromSignature(signature) {
329
+ const refs = new Set();
330
+ for (const entry of signature) {
331
+ const ref = entry.split(':')[0]?.trim();
332
+ if (ref) {
333
+ refs.add(ref);
334
+ }
335
+ if (refs.size >= 8) {
336
+ break;
337
+ }
338
+ }
339
+ return [...refs];
340
+ }
341
+ function compactRemoteQuery(lines, fallback) {
342
+ const text = lines
343
+ .map((line) => line.replace(/\s+/gu, ' ').trim())
344
+ .filter(Boolean)
345
+ .join('\n')
346
+ .slice(0, 1800)
347
+ .trim();
348
+ if (text) {
349
+ return text;
350
+ }
351
+ return fallback ? fallback.slice(0, 1800).trim() || null : null;
352
+ }
353
+ function fallbackRemoteQueryText(profile) {
354
+ return (Object.values(profile.fields)
355
+ .flat()
356
+ .find((value) => typeof value === 'string' && value.trim().length > 0) ??
357
+ profile.identity_key);
358
+ }
359
+ function flowRemoteQuery(profile) {
360
+ const lines = [];
361
+ appendQueryLine(lines, 'flow name', profile.names, 4);
362
+ appendQueryLine(lines, 'flow type', profile.fields.type_of_dataset);
363
+ appendQueryLine(lines, 'CAS', profile.fields.cas);
364
+ appendQueryLine(lines, 'reference property', profile.fields.flow_property);
365
+ appendQueryLine(lines, 'reference unit', profile.fields.reference_unit);
366
+ appendQueryLine(lines, 'category or compartment', profile.fields.categories, 6);
367
+ appendQueryLine(lines, 'geography or market', profile.fields.geography);
368
+ return compactRemoteQuery(lines, fallbackRemoteQueryText(profile));
369
+ }
370
+ function processRemoteQuery(profile) {
371
+ const lines = [];
372
+ appendQueryLine(lines, 'process name', profile.names, 4);
373
+ appendQueryLine(lines, 'reference flow', [
374
+ ...queryFieldValues(profile.fields.reference_flow_names, 4),
375
+ ...queryFieldValues(profile.fields.reference_flow_ids, 4),
376
+ ], 8);
377
+ appendQueryLine(lines, 'quantitative reference', profile.fields.quantitative_reference);
378
+ appendQueryLine(lines, 'geography', profile.fields.geography);
379
+ appendQueryLine(lines, 'time', profile.fields.time);
380
+ appendQueryLine(lines, 'classification or sector', profile.fields.categories, 6);
381
+ appendQueryLine(lines, 'technology route', profile.fields.technology_route);
382
+ appendQueryLine(lines, 'system boundary', profile.fields.system_boundary);
383
+ appendQueryLine(lines, 'operation', profile.fields.operation);
384
+ appendQueryLine(lines, 'provider role', profile.fields.provider_role);
385
+ appendQueryLine(lines, 'exchange flow refs', exchangeFlowRefsFromSignature(profile.exchange_signature), 8);
386
+ return compactRemoteQuery(lines, fallbackRemoteQueryText(profile));
387
+ }
215
388
  function defaultRemoteQuery(profile) {
216
- return (profile.names[0] ??
217
- Object.values(profile.fields)
218
- .flat()
219
- .find((value) => typeof value === 'string' && value.trim().length > 0) ??
220
- (profile.identity_key ? profile.identity_key : null));
389
+ const hasProcessFields = 'reference_flow_ids' in profile.fields ||
390
+ 'technology_route' in profile.fields ||
391
+ profile.exchange_signature.length > 0;
392
+ return hasProcessFields ? processRemoteQuery(profile) : flowRemoteQuery(profile);
221
393
  }
222
394
  function remoteSearchFilter(kind, profile, explicitFilter) {
223
395
  const filter = explicitFilter ? { ...explicitFilter } : {};
@@ -231,6 +403,20 @@ function remoteSearchFilter(kind, profile, explicitFilter) {
231
403
  }
232
404
  return Object.keys(filter).length > 0 ? filter : null;
233
405
  }
406
+ function remoteSearchOptions(config) {
407
+ const options = {
408
+ ...(config.matchThreshold !== null ? { match_threshold: config.matchThreshold } : {}),
409
+ ...(config.fullTextWeight !== null ? { full_text_weight: config.fullTextWeight } : {}),
410
+ ...(config.extractedTextWeight !== null
411
+ ? { extracted_text_weight: config.extractedTextWeight }
412
+ : {}),
413
+ ...(config.semanticWeight !== null ? { semantic_weight: config.semanticWeight } : {}),
414
+ ...(config.rrfK !== null ? { rrf_k: config.rrfK } : {}),
415
+ ...(config.pageSize !== null ? { page_size: config.pageSize } : {}),
416
+ ...(config.pageCurrent !== null ? { page_current: config.pageCurrent } : {}),
417
+ };
418
+ return Object.keys(options).length > 0 ? options : null;
419
+ }
234
420
  function rowsFromRemoteSearchResponse(value) {
235
421
  const rows = isRecord(value)
236
422
  ? (value.data ?? value.rows ?? value.results ?? value.candidates ?? [])
@@ -264,10 +450,21 @@ async function readRemoteCandidateSource(kind, targetProfile, config, options) {
264
450
  now: options.now,
265
451
  });
266
452
  const filter = remoteSearchFilter(kind, targetProfile, config.filter);
453
+ const searchOptions = remoteSearchOptions(config);
454
+ const pageSize = config.pageSize ?? config.limit;
455
+ const sourceOptions = {
456
+ ...(config.limit ? { limit: config.limit, match_count: config.limit } : {}),
457
+ ...(pageSize ? { page_size: pageSize } : {}),
458
+ ...(config.dataSource ? { data_source: config.dataSource } : {}),
459
+ ...(searchOptions ?? {}),
460
+ };
267
461
  const body = {
268
462
  query,
269
463
  ...(filter ? { filter } : {}),
270
- ...(config.limit ? { limit: config.limit } : {}),
464
+ ...(config.limit ? { match_count: config.limit } : {}),
465
+ ...(pageSize ? { page_size: pageSize } : {}),
466
+ ...(config.dataSource ? { data_source: config.dataSource } : {}),
467
+ ...(searchOptions ?? {}),
271
468
  };
272
469
  const headers = {
273
470
  Authorization: `Bearer ${session.accessToken}`,
@@ -295,6 +492,7 @@ async function readRemoteCandidateSource(kind, targetProfile, config, options) {
295
492
  endpoint,
296
493
  query,
297
494
  filter,
495
+ options: Object.keys(sourceOptions).length > 0 ? sourceOptions : null,
298
496
  },
299
497
  };
300
498
  }
@@ -414,7 +612,7 @@ function collectText(value, output = []) {
414
612
  output.push(textValue(value['#text']));
415
613
  }
416
614
  for (const [key, entry] of Object.entries(value)) {
417
- if (key === '#text') {
615
+ if (key === '#text' || key.startsWith('@')) {
418
616
  continue;
419
617
  }
420
618
  collectText(entry, output);
@@ -452,9 +650,50 @@ function uniqueTexts(values) {
452
650
  }
453
651
  return [...normalized.values()].sort((a, b) => normalizeText(a).localeCompare(normalizeText(b)));
454
652
  }
653
+ function uniqueTextsInOrder(values) {
654
+ const normalized = new Map();
655
+ for (const value of values) {
656
+ for (const text of collectText(value)) {
657
+ const key = normalizeText(text);
658
+ if (key && !normalized.has(key)) {
659
+ normalized.set(key, text.trim());
660
+ }
661
+ }
662
+ }
663
+ return [...normalized.values()];
664
+ }
455
665
  function firstUniqueText(...values) {
456
666
  return uniqueTexts(values)[0] ?? null;
457
667
  }
668
+ function firstTextInOrder(...values) {
669
+ return uniqueTextsInOrder(values)[0] ?? null;
670
+ }
671
+ function pathValue(value, keys) {
672
+ let current = value;
673
+ for (const key of keys) {
674
+ if (!isRecord(current)) {
675
+ return undefined;
676
+ }
677
+ current = current[key];
678
+ }
679
+ return current;
680
+ }
681
+ function textAtPath(value, keys) {
682
+ return firstTextInOrder(pathValue(value, keys));
683
+ }
684
+ function textsAtPath(value, keys) {
685
+ return uniqueTextsInOrder([pathValue(value, keys)]);
686
+ }
687
+ function recordsFromValue(value) {
688
+ const values = Array.isArray(value) ? value : [value];
689
+ return values.filter(isRecord);
690
+ }
691
+ function meaningfulTexts(values) {
692
+ return values.filter((value) => !isRemoteQueryNoiseText(value));
693
+ }
694
+ function meaningfulText(value) {
695
+ return value && !isRemoteQueryNoiseText(value) ? value : null;
696
+ }
458
697
  function fieldFromKeys(row, payload, keys) {
459
698
  const wanted = new Set(keys.map(normalizeKey));
460
699
  return firstUniqueText(...collectValuesByKey(row, wanted), ...collectValuesByKey(payload, wanted));
@@ -500,29 +739,29 @@ function collectExchangeLikeRecords(value, output = []) {
500
739
  return output;
501
740
  }
502
741
  function exchangeRecordSignature(record) {
503
- const flowId = fieldFromKeys(record, record, [
504
- '@refObjectId',
505
- 'refObjectId',
506
- 'flow_id',
507
- 'flowId',
508
- 'flow_uuid',
509
- 'flowUuid',
510
- 'referenceToFlowDataSet',
511
- ]);
512
- const direction = fieldFromKeys(record, record, [
513
- 'exchangeDirection',
514
- 'direction',
515
- 'inputGroup',
516
- 'outputGroup',
517
- ]);
518
- const amount = fieldFromKeys(record, record, [
519
- 'meanAmount',
520
- 'mean_amount',
521
- 'resultingAmount',
522
- 'resulting_amount',
523
- 'amount',
524
- 'meanValue',
525
- ]);
742
+ const flowReference = isRecord(record.referenceToFlowDataSet)
743
+ ? record.referenceToFlowDataSet
744
+ : null;
745
+ const flowId = firstTextInOrder(flowReference?.['@refObjectId'], record['@refObjectId'], record.refObjectId, record.flow_id, record.flowId, record.flow_uuid, record.flowUuid) ??
746
+ fieldFromKeys(record, record, [
747
+ '@refObjectId',
748
+ 'refObjectId',
749
+ 'flow_id',
750
+ 'flowId',
751
+ 'flow_uuid',
752
+ 'flowUuid',
753
+ ]);
754
+ const direction = firstTextInOrder(record.exchangeDirection, record.direction, record.inputGroup, record.outputGroup) ??
755
+ fieldFromKeys(record, record, ['exchangeDirection', 'direction', 'inputGroup', 'outputGroup']);
756
+ const amount = firstTextInOrder(record.meanAmount, record.mean_amount, record.resultingAmount, record.resulting_amount, record.amount, record.meanValue) ??
757
+ fieldFromKeys(record, record, [
758
+ 'meanAmount',
759
+ 'mean_amount',
760
+ 'resultingAmount',
761
+ 'resulting_amount',
762
+ 'amount',
763
+ 'meanValue',
764
+ ]);
526
765
  const normalizedFlowId = flowId ? normalizeText(flowId) : '';
527
766
  if (!normalizedFlowId) {
528
767
  return null;
@@ -544,60 +783,196 @@ function profileDatasetIdentity(row, payload, kind) {
544
783
  version: firstUniqueText(row.version, row.dataset_version, identity.version) ?? identity.version,
545
784
  };
546
785
  }
786
+ function tidasRoot(payload, kind) {
787
+ if (kind === 'process' && isRecord(payload.processDataSet)) {
788
+ return payload.processDataSet;
789
+ }
790
+ if (kind === 'flow' && isRecord(payload.flowDataSet)) {
791
+ return payload.flowDataSet;
792
+ }
793
+ return null;
794
+ }
795
+ function processCanonicalNames(root) {
796
+ const nameRoot = pathValue(root, ['processInformation', 'dataSetInformation', 'name']);
797
+ return meaningfulTexts(uniqueTextsInOrder([
798
+ pathValue(nameRoot, ['baseName']),
799
+ pathValue(nameRoot, ['treatmentStandardsRoutes']),
800
+ pathValue(nameRoot, ['mixAndLocationTypes']),
801
+ pathValue(nameRoot, ['functionalUnitFlowProperties']),
802
+ ]));
803
+ }
804
+ function flowCanonicalNames(root) {
805
+ const nameRoot = pathValue(root, ['flowInformation', 'dataSetInformation', 'name']);
806
+ return meaningfulTexts(uniqueTextsInOrder([
807
+ pathValue(nameRoot, ['baseName']),
808
+ pathValue(nameRoot, ['treatmentStandardsRoutes']),
809
+ pathValue(nameRoot, ['mixAndLocationTypes']),
810
+ ]));
811
+ }
812
+ function classificationTexts(root, informationPath) {
813
+ const classes = recordsFromValue(pathValue(root, [
814
+ ...informationPath,
815
+ 'dataSetInformation',
816
+ 'classificationInformation',
817
+ 'common:classification',
818
+ 'common:class',
819
+ ]));
820
+ return meaningfulTexts(uniqueTextsInOrder(classes.flatMap((entry) => [entry['@classId'], entry['#text']])));
821
+ }
822
+ function elementaryFlowCategoryTexts(root) {
823
+ const categories = recordsFromValue(pathValue(root, [
824
+ 'flowInformation',
825
+ 'dataSetInformation',
826
+ 'classificationInformation',
827
+ 'common:elementaryFlowCategorization',
828
+ 'common:category',
829
+ ]));
830
+ return meaningfulTexts(uniqueTextsInOrder(categories.flatMap((entry) => [entry['@level'], entry['#text']]))).filter((entry) => !/^\d+$/u.test(entry));
831
+ }
832
+ function processReferenceExchange(root) {
833
+ const referenceFlowInternalIds = textsAtPath(root, [
834
+ 'processInformation',
835
+ 'quantitativeReference',
836
+ 'referenceToReferenceFlow',
837
+ ]);
838
+ const internalIdSet = new Set(referenceFlowInternalIds.map(normalizeText));
839
+ const exchanges = recordsFromValue(pathValue(root, ['exchanges', 'exchange']));
840
+ const referenceExchanges = internalIdSet.size > 0
841
+ ? exchanges.filter((exchange) => internalIdSet.has(normalizeText(textValue(exchange['@dataSetInternalID']) ?? '')))
842
+ : [];
843
+ return referenceExchanges[0] ?? exchanges[0] ?? null;
844
+ }
845
+ function processReferenceFlowValues(root) {
846
+ const selected = processReferenceExchange(root);
847
+ if (!selected) {
848
+ return { ids: [], names: [] };
849
+ }
850
+ const reference = isRecord(selected.referenceToFlowDataSet)
851
+ ? selected.referenceToFlowDataSet
852
+ : {};
853
+ return {
854
+ ids: meaningfulTexts(uniqueTextsInOrder([reference['@refObjectId'], selected['@refObjectId']])),
855
+ names: meaningfulTexts(uniqueTextsInOrder([reference['common:shortDescription'], reference.shortDescription])),
856
+ };
857
+ }
858
+ function processCanonicalFields(root) {
859
+ const names = processCanonicalNames(root);
860
+ const referenceFlow = processReferenceFlowValues(root);
861
+ return {
862
+ names,
863
+ referenceFlowIds: referenceFlow.ids,
864
+ referenceFlowNames: referenceFlow.names,
865
+ quantitativeReference: meaningfulText(textAtPath(root, ['processInformation', 'quantitativeReference', 'functionalUnitOrOther'])) ??
866
+ meaningfulText(textAtPath(root, [
867
+ 'processInformation',
868
+ 'quantitativeReference',
869
+ 'referenceToReferenceFlow',
870
+ ])),
871
+ geography: meaningfulText(textAtPath(root, [
872
+ 'processInformation',
873
+ 'geography',
874
+ 'locationOfOperationSupplyOrProduction',
875
+ '@location',
876
+ ])),
877
+ time: meaningfulText(firstTextInOrder(pathValue(root, ['processInformation', 'time', 'common:referenceYear']), pathValue(root, ['processInformation', 'time', 'common:timeRepresentativenessDescription']))),
878
+ technologyRoute: meaningfulText(textAtPath(root, [
879
+ 'processInformation',
880
+ 'technology',
881
+ 'technologyDescriptionAndIncludedProcesses',
882
+ ])),
883
+ systemBoundary: meaningfulText(textAtPath(root, ['processInformation', 'technology', 'includedProcesses'])),
884
+ categories: classificationTexts(root, ['processInformation']),
885
+ };
886
+ }
887
+ function flowCanonicalFields(root) {
888
+ const flowProperties = recordsFromValue(pathValue(root, ['flowProperties', 'flowProperty']));
889
+ const typeOfDataset = meaningfulText(textAtPath(root, ['modellingAndValidation', 'LCIMethod', 'typeOfDataSet']));
890
+ const elementaryCategories = typeOfDataset === 'Elementary flow' ? elementaryFlowCategoryTexts(root) : [];
891
+ return {
892
+ names: flowCanonicalNames(root),
893
+ typeOfDataset,
894
+ cas: meaningfulText(textAtPath(root, ['flowInformation', 'dataSetInformation', 'CASNumber'])),
895
+ flowProperty: meaningfulText(firstTextInOrder(...flowProperties.map((property) => pathValue(property, ['referenceToFlowPropertyDataSet', 'common:shortDescription'])))),
896
+ referenceUnit: null,
897
+ categories: elementaryCategories.length
898
+ ? elementaryCategories
899
+ : classificationTexts(root, ['flowInformation']),
900
+ geography: meaningfulText(textAtPath(root, ['flowInformation', 'dataSetInformation', 'name', 'mixAndLocationTypes'])),
901
+ };
902
+ }
547
903
  function processProfile(row) {
548
904
  const payload = unwrapDatasetPayload(row);
549
905
  const identity = profileDatasetIdentity(row, payload, 'process');
550
- const names = textListFromKeys(row, payload, [
551
- 'name',
552
- 'baseName',
553
- 'shortDescription',
554
- 'name_en',
555
- 'name_zh',
556
- ]);
557
- const referenceFlowIds = textListFromKeys(row, payload, [
558
- 'reference_flow_id',
559
- 'referenceFlowId',
560
- 'reference_product_flow',
561
- 'referenceProductFlow',
562
- 'referenceToReferenceFlow',
563
- 'referenceToFlowDataSet',
564
- '@refObjectId',
565
- 'refObjectId',
566
- ]);
906
+ const canonicalRoot = tidasRoot(payload, 'process');
907
+ const canonical = canonicalRoot ? processCanonicalFields(canonicalRoot) : null;
908
+ const names = canonical?.names.length
909
+ ? canonical.names
910
+ : textListFromKeys(row, payload, [
911
+ 'name',
912
+ 'baseName',
913
+ 'shortDescription',
914
+ 'name_en',
915
+ 'name_zh',
916
+ ]);
917
+ const referenceFlowIds = canonical?.referenceFlowIds.length
918
+ ? canonical.referenceFlowIds
919
+ : textListFromKeys(row, payload, [
920
+ 'reference_flow_id',
921
+ 'referenceFlowId',
922
+ 'reference_product_flow',
923
+ 'referenceProductFlow',
924
+ 'referenceToReferenceFlow',
925
+ 'referenceToFlowDataSet',
926
+ '@refObjectId',
927
+ 'refObjectId',
928
+ ]);
929
+ const referenceFlowNames = canonical?.referenceFlowNames.length
930
+ ? canonical.referenceFlowNames
931
+ : textListFromKeys(row, payload, [
932
+ 'reference_flow_name',
933
+ 'referenceFlowName',
934
+ 'reference_product_flow_name',
935
+ 'referenceProductFlowName',
936
+ ]);
567
937
  const operation = fieldFromKeys(row, payload, ['operation', 'process_operation']);
568
- const quantitativeReference = fieldFromKeys(row, payload, [
569
- 'quantitative_reference',
570
- 'quantitativeReference',
571
- 'qref',
572
- 'referenceToReferenceFlow',
573
- ]);
574
- const geography = fieldFromKeys(row, payload, [
575
- 'geography',
576
- 'location',
577
- 'locationOfOperationSupplyOrProduction',
578
- ]);
579
- const time = fieldFromKeys(row, payload, [
580
- 'time',
581
- 'reference_year',
582
- 'referenceYear',
583
- 'timePeriod',
584
- ]);
585
- const technologyRoute = fieldFromKeys(row, payload, [
586
- 'technology_route',
587
- 'technologyRoute',
588
- 'technology',
589
- 'treatmentStandardsRoutes',
590
- ]);
591
- const systemBoundary = fieldFromKeys(row, payload, [
592
- 'system_boundary',
593
- 'systemBoundary',
594
- 'boundary',
595
- ]);
938
+ const quantitativeReference = canonicalRoot && canonical
939
+ ? canonical.quantitativeReference
940
+ : fieldFromKeys(row, payload, [
941
+ 'quantitative_reference',
942
+ 'quantitativeReference',
943
+ 'qref',
944
+ 'referenceToReferenceFlow',
945
+ ]);
946
+ const geography = canonicalRoot && canonical
947
+ ? canonical.geography
948
+ : fieldFromKeys(row, payload, [
949
+ 'geography',
950
+ 'location',
951
+ 'locationOfOperationSupplyOrProduction',
952
+ ]);
953
+ const time = canonicalRoot && canonical
954
+ ? canonical.time
955
+ : fieldFromKeys(row, payload, ['time', 'reference_year', 'referenceYear', 'timePeriod']);
956
+ const technologyRoute = canonicalRoot && canonical
957
+ ? canonical.technologyRoute
958
+ : fieldFromKeys(row, payload, [
959
+ 'technology_route',
960
+ 'technologyRoute',
961
+ 'technology',
962
+ 'treatmentStandardsRoutes',
963
+ ]);
964
+ const systemBoundary = canonicalRoot && canonical
965
+ ? canonical.systemBoundary
966
+ : fieldFromKeys(row, payload, ['system_boundary', 'systemBoundary', 'boundary']);
596
967
  const providerRole = fieldFromKeys(row, payload, ['provider_role', 'providerRole']);
968
+ const categories = canonical?.categories.length
969
+ ? canonical.categories
970
+ : textListFromKeys(row, payload, ['category', 'class', 'classification']);
597
971
  const exchangeSignature = processExchangeSignature(row, payload);
598
972
  const keyParts = [
599
973
  ...normalizedList(names).slice(0, 4),
600
974
  ...normalizedList(referenceFlowIds).slice(0, 4),
975
+ ...normalizedList(referenceFlowNames).slice(0, 2),
601
976
  normalizeText(operation ?? ''),
602
977
  normalizeText(quantitativeReference ?? ''),
603
978
  normalizeText(geography ?? ''),
@@ -605,6 +980,7 @@ function processProfile(row) {
605
980
  normalizeText(technologyRoute ?? ''),
606
981
  normalizeText(systemBoundary ?? ''),
607
982
  normalizeText(providerRole ?? ''),
983
+ ...normalizedList(categories).slice(0, 4),
608
984
  exchangeSignature.join(','),
609
985
  ].filter(Boolean);
610
986
  return {
@@ -617,6 +993,7 @@ function processProfile(row) {
617
993
  exchange_signature: exchangeSignature,
618
994
  fields: {
619
995
  reference_flow_ids: referenceFlowIds,
996
+ reference_flow_names: referenceFlowNames,
620
997
  operation,
621
998
  quantitative_reference: quantitativeReference,
622
999
  geography,
@@ -624,42 +1001,49 @@ function processProfile(row) {
624
1001
  technology_route: technologyRoute,
625
1002
  system_boundary: systemBoundary,
626
1003
  provider_role: providerRole,
1004
+ categories,
627
1005
  },
628
1006
  };
629
1007
  }
630
1008
  function flowProfile(row) {
631
1009
  const payload = unwrapDatasetPayload(row);
632
1010
  const identity = profileDatasetIdentity(row, payload, 'flow');
633
- const names = textListFromKeys(row, payload, [
634
- 'name',
635
- 'baseName',
636
- 'shortDescription',
637
- 'name_en',
638
- 'name_zh',
639
- 'synonyms',
640
- ]);
641
- const typeOfDataset = fieldFromKeys(row, payload, [
642
- 'type_of_dataset',
643
- 'typeOfDataSet',
644
- 'flow_type',
645
- 'flowType',
646
- ]);
647
- const cas = fieldFromKeys(row, payload, ['CASNumber', 'cas_number', 'cas']);
648
- const flowProperty = fieldFromKeys(row, payload, [
649
- 'flow_property',
650
- 'flowProperty',
651
- 'referenceToFlowPropertyDataSet',
652
- 'reference_property',
653
- 'referenceProperty',
654
- ]);
655
- const referenceUnit = fieldFromKeys(row, payload, ['reference_unit', 'referenceUnit', 'unit']);
656
- const categories = textListFromKeys(row, payload, ['category', 'compartment']);
657
- const geography = fieldFromKeys(row, payload, [
658
- 'geography',
659
- 'location',
660
- 'market',
661
- 'mixAndLocationTypes',
662
- ]);
1011
+ const canonicalRoot = tidasRoot(payload, 'flow');
1012
+ const canonical = canonicalRoot ? flowCanonicalFields(canonicalRoot) : null;
1013
+ const names = canonical?.names.length
1014
+ ? canonical.names
1015
+ : textListFromKeys(row, payload, [
1016
+ 'name',
1017
+ 'baseName',
1018
+ 'shortDescription',
1019
+ 'name_en',
1020
+ 'name_zh',
1021
+ 'synonyms',
1022
+ ]);
1023
+ const typeOfDataset = canonicalRoot && canonical
1024
+ ? canonical.typeOfDataset
1025
+ : fieldFromKeys(row, payload, ['type_of_dataset', 'typeOfDataSet', 'flow_type', 'flowType']);
1026
+ const cas = canonicalRoot && canonical
1027
+ ? canonical.cas
1028
+ : fieldFromKeys(row, payload, ['CASNumber', 'cas_number', 'cas']);
1029
+ const flowProperty = canonicalRoot && canonical
1030
+ ? canonical.flowProperty
1031
+ : fieldFromKeys(row, payload, [
1032
+ 'flow_property',
1033
+ 'flowProperty',
1034
+ 'referenceToFlowPropertyDataSet',
1035
+ 'reference_property',
1036
+ 'referenceProperty',
1037
+ ]);
1038
+ const referenceUnit = canonicalRoot && canonical
1039
+ ? canonical.referenceUnit
1040
+ : fieldFromKeys(row, payload, ['reference_unit', 'referenceUnit', 'unit']);
1041
+ const categories = canonical?.categories.length
1042
+ ? canonical.categories
1043
+ : textListFromKeys(row, payload, ['category', 'class', 'classification', 'compartment']);
1044
+ const geography = canonicalRoot && canonical
1045
+ ? canonical.geography
1046
+ : fieldFromKeys(row, payload, ['geography', 'location', 'market', 'mixAndLocationTypes']);
663
1047
  const keyParts = [
664
1048
  normalizeText(typeOfDataset ?? ''),
665
1049
  ...normalizedList(names).slice(0, 4),
@@ -687,6 +1071,98 @@ function flowProfile(row) {
687
1071
  },
688
1072
  };
689
1073
  }
1074
+ const PROFILE_ARRAY_FIELDS = new Set(['categories', 'reference_flow_ids', 'reference_flow_names']);
1075
+ const PROFILE_HINT_KEYS = {
1076
+ flow: {
1077
+ type_of_dataset: ['type_of_dataset', 'typeOfDataSet', 'flow_type', 'flowType'],
1078
+ cas: ['cas', 'CAS', 'CASNumber', 'cas_number'],
1079
+ flow_property: ['flow_property', 'flowProperty', 'reference_property', 'referenceProperty'],
1080
+ reference_unit: ['reference_unit', 'referenceUnit', 'unit'],
1081
+ categories: ['categories', 'category', 'classification', 'source_categories'],
1082
+ geography: ['geography', 'location', 'market'],
1083
+ },
1084
+ process: {
1085
+ reference_flow_ids: ['reference_flow_ids', 'referenceFlowIds', 'reference_flow_id'],
1086
+ reference_flow_names: ['reference_flow_names', 'referenceFlowNames', 'reference_flow_name'],
1087
+ operation: ['operation', 'process_operation'],
1088
+ quantitative_reference: ['quantitative_reference', 'quantitativeReference', 'qref'],
1089
+ geography: ['geography', 'location'],
1090
+ time: ['time', 'reference_year', 'referenceYear', 'timePeriod'],
1091
+ technology_route: ['technology_route', 'technologyRoute', 'technology'],
1092
+ system_boundary: ['system_boundary', 'systemBoundary', 'boundary'],
1093
+ provider_role: ['provider_role', 'providerRole'],
1094
+ categories: ['categories', 'category', 'classification', 'source_categories'],
1095
+ },
1096
+ };
1097
+ function hintTextValues(value, limit = 8) {
1098
+ return uniqueTextsInOrder([value])
1099
+ .map((entry) => entry.trim())
1100
+ .filter((entry) => !isRemoteQueryNoiseText(entry))
1101
+ .slice(0, limit);
1102
+ }
1103
+ function hintValuesByKeys(hints, keys, limit = 8) {
1104
+ const values = [];
1105
+ for (const key of keys) {
1106
+ if (hints[key] !== undefined) {
1107
+ values.push(hints[key]);
1108
+ }
1109
+ }
1110
+ return hintTextValues(values, limit);
1111
+ }
1112
+ function identityKeyFromProfile(kind, names, fields, exchangeSignature) {
1113
+ if (kind === 'process') {
1114
+ return [
1115
+ ...normalizedList(names).slice(0, 4),
1116
+ ...normalizedList(normalizedFieldValues(fields.reference_flow_ids)).slice(0, 4),
1117
+ ...normalizedList(normalizedFieldValues(fields.reference_flow_names)).slice(0, 2),
1118
+ normalizeText(typeof fields.operation === 'string' ? fields.operation : ''),
1119
+ normalizeText(typeof fields.quantitative_reference === 'string' ? fields.quantitative_reference : ''),
1120
+ normalizeText(typeof fields.geography === 'string' ? fields.geography : ''),
1121
+ normalizeText(typeof fields.time === 'string' ? fields.time : ''),
1122
+ normalizeText(typeof fields.technology_route === 'string' ? fields.technology_route : ''),
1123
+ normalizeText(typeof fields.system_boundary === 'string' ? fields.system_boundary : ''),
1124
+ normalizeText(typeof fields.provider_role === 'string' ? fields.provider_role : ''),
1125
+ ...normalizedList(normalizedFieldValues(fields.categories)).slice(0, 4),
1126
+ exchangeSignature.join(','),
1127
+ ]
1128
+ .filter(Boolean)
1129
+ .join('|');
1130
+ }
1131
+ return [
1132
+ normalizeText(typeof fields.type_of_dataset === 'string' ? fields.type_of_dataset : ''),
1133
+ ...normalizedList(names).slice(0, 4),
1134
+ normalizeText(typeof fields.cas === 'string' ? fields.cas : ''),
1135
+ normalizeText(typeof fields.flow_property === 'string' ? fields.flow_property : ''),
1136
+ normalizeText(typeof fields.reference_unit === 'string' ? fields.reference_unit : ''),
1137
+ ...normalizedList(normalizedFieldValues(fields.categories)).slice(0, 4),
1138
+ normalizeText(typeof fields.geography === 'string' ? fields.geography : ''),
1139
+ ]
1140
+ .filter(Boolean)
1141
+ .join('|');
1142
+ }
1143
+ function applyIdentityProfileHints(profile, hints, kind) {
1144
+ if (!hints) {
1145
+ return profile;
1146
+ }
1147
+ const hintedNames = hintValuesByKeys(hints, ['names', 'name', 'name_en', 'name_zh'], 6);
1148
+ const names = hintedNames.length > 0 ? hintedNames : profile.names;
1149
+ const fields = { ...profile.fields };
1150
+ const fieldHints = PROFILE_HINT_KEYS[kind];
1151
+ for (const [field, keys] of Object.entries(fieldHints)) {
1152
+ const values = hintValuesByKeys(hints, keys, PROFILE_ARRAY_FIELDS.has(field) ? 12 : 4);
1153
+ if (values.length === 0) {
1154
+ continue;
1155
+ }
1156
+ fields[field] = PROFILE_ARRAY_FIELDS.has(field) ? values : values[0];
1157
+ }
1158
+ return {
1159
+ ...profile,
1160
+ names,
1161
+ normalized_names: normalizedList(names),
1162
+ fields,
1163
+ identity_key: identityKeyFromProfile(kind, names, fields, profile.exchange_signature),
1164
+ };
1165
+ }
690
1166
  function profileForKind(row, kind) {
691
1167
  return kind === 'process' ? processProfile(row) : flowProfile(row);
692
1168
  }
@@ -694,6 +1170,116 @@ function intersects(left, right) {
694
1170
  const rightSet = new Set(right);
695
1171
  return left.some((entry) => rightSet.has(entry));
696
1172
  }
1173
+ function normalizedNamePhrase(names) {
1174
+ return normalizeText(names.join(' '));
1175
+ }
1176
+ const FLOW_NAME_EQUIVALENTS = new Map([
1177
+ ['dinitrogen monoxide', 'nitrous oxide'],
1178
+ ['ethene', 'ethylene'],
1179
+ ['ethylene', 'ethylene'],
1180
+ ['heat waste', 'waste heat'],
1181
+ ['nitrous oxide', 'nitrous oxide'],
1182
+ ['pah polycyclic aromatic hydrocarbons', 'polycyclic aromatic hydrocarbons'],
1183
+ ['polycyclic aromatic hydrocarbons', 'polycyclic aromatic hydrocarbons'],
1184
+ ['waste heat', 'waste heat'],
1185
+ ]);
1186
+ function normalizeCas(value) {
1187
+ return value.replace(/\D+/gu, '').replace(/^0+/u, '');
1188
+ }
1189
+ function normalizeFlowNameVariant(value) {
1190
+ const normalized = normalizeText(value).replace(/\bsulphur\b/gu, 'sulfur');
1191
+ return FLOW_NAME_EQUIVALENTS.get(normalized) ?? normalized;
1192
+ }
1193
+ function expandedFlowNameVariants(value) {
1194
+ const normalized = normalizeFlowNameVariant(value);
1195
+ const variants = [normalized];
1196
+ const transformation = normalized.match(/^transformation\s+(to|from)\s+(.+)$/u);
1197
+ if (transformation?.[1] && transformation[2]) {
1198
+ variants.push(`${transformation[1]} ${transformation[2]}`);
1199
+ }
1200
+ const occupation = normalized.match(/^occupation\s+(.+)$/u);
1201
+ if (occupation?.[1]) {
1202
+ variants.push(`occupation ${occupation[1]}`);
1203
+ }
1204
+ return variants;
1205
+ }
1206
+ function flowNameVariants(names) {
1207
+ return normalizedList(names).flatMap(expandedFlowNameVariants).filter(Boolean);
1208
+ }
1209
+ function nameTokens(names) {
1210
+ return new Set(normalizedNamePhrase(names)
1211
+ .split(' ')
1212
+ .map((entry) => entry.trim())
1213
+ .filter((entry) => entry.length >= 2));
1214
+ }
1215
+ function tokenOverlapRatio(left, right) {
1216
+ if (left.size === 0 || right.size === 0) {
1217
+ return 0;
1218
+ }
1219
+ let overlap = 0;
1220
+ for (const token of left) {
1221
+ if (right.has(token)) {
1222
+ overlap += 1;
1223
+ }
1224
+ }
1225
+ return overlap / Math.max(left.size, right.size);
1226
+ }
1227
+ function tokenCoverageRatio(left, right) {
1228
+ if (left.size === 0 || right.size === 0) {
1229
+ return 0;
1230
+ }
1231
+ let overlap = 0;
1232
+ for (const token of left) {
1233
+ if (right.has(token)) {
1234
+ overlap += 1;
1235
+ }
1236
+ }
1237
+ return overlap / left.size;
1238
+ }
1239
+ function coversLongTargetTokens(left, right) {
1240
+ return [...left].filter((token) => token.length >= 5).every((token) => right.has(token));
1241
+ }
1242
+ function hasSimilarNamePhrase(target, candidate) {
1243
+ const targetPhrase = normalizedNamePhrase(target.names);
1244
+ const candidatePhrase = normalizedNamePhrase(candidate.names);
1245
+ if (targetPhrase.length >= 8 && candidatePhrase.length >= 8) {
1246
+ if (targetPhrase.includes(candidatePhrase) || candidatePhrase.includes(targetPhrase)) {
1247
+ return true;
1248
+ }
1249
+ }
1250
+ const targetTokens = nameTokens(target.names);
1251
+ const candidateTokens = nameTokens(candidate.names);
1252
+ const coversTargetQualifiers = coversLongTargetTokens(targetTokens, candidateTokens);
1253
+ return ((coversTargetQualifiers && tokenOverlapRatio(targetTokens, candidateTokens) >= 0.66) ||
1254
+ (targetTokens.size >= 3 &&
1255
+ tokenCoverageRatio(targetTokens, candidateTokens) >= 0.8 &&
1256
+ coversTargetQualifiers));
1257
+ }
1258
+ function hasStrongEquivalentFlowName(target, candidate) {
1259
+ return (intersects(target.normalized_names, candidate.normalized_names) ||
1260
+ intersects(flowNameVariants(target.names), flowNameVariants(candidate.names)));
1261
+ }
1262
+ function hasEquivalentFlowName(target, candidate) {
1263
+ return hasStrongEquivalentFlowName(target, candidate) || hasSimilarNamePhrase(target, candidate);
1264
+ }
1265
+ function isElementaryFlowProfile(profile) {
1266
+ return sameNonEmptyField(profile.fields.type_of_dataset, 'Elementary flow');
1267
+ }
1268
+ function hasConflictingFlowName(target, candidate) {
1269
+ if (!isElementaryFlowProfile(target) || !isElementaryFlowProfile(candidate)) {
1270
+ return false;
1271
+ }
1272
+ if (target.names.length === 0 || candidate.names.length === 0) {
1273
+ return false;
1274
+ }
1275
+ if (hasEquivalentFlowName(target, candidate)) {
1276
+ return false;
1277
+ }
1278
+ if (sameCasField(target.fields.cas, candidate.fields.cas)) {
1279
+ return false;
1280
+ }
1281
+ return true;
1282
+ }
697
1283
  function normalizedFieldValues(value) {
698
1284
  return (Array.isArray(value) ? value : [value])
699
1285
  .filter((entry) => typeof entry === 'string')
@@ -705,6 +1291,90 @@ function sameNonEmptyField(left, right) {
705
1291
  const rightValues = normalizedFieldValues(right);
706
1292
  return leftValues.length > 0 && rightValues.length > 0 && intersects(leftValues, rightValues);
707
1293
  }
1294
+ function sameCasField(left, right) {
1295
+ const leftValues = (Array.isArray(left) ? left : [left])
1296
+ .filter((entry) => typeof entry === 'string')
1297
+ .map(normalizeCas)
1298
+ .filter(Boolean);
1299
+ const rightValues = (Array.isArray(right) ? right : [right])
1300
+ .filter((entry) => typeof entry === 'string')
1301
+ .map(normalizeCas)
1302
+ .filter(Boolean);
1303
+ return leftValues.length > 0 && rightValues.length > 0 && intersects(leftValues, rightValues);
1304
+ }
1305
+ function lastNormalizedValue(value) {
1306
+ const values = normalizedFieldValues(value);
1307
+ return values.length > 0 ? values[values.length - 1] : null;
1308
+ }
1309
+ function sameCategoryLeaf(left, right) {
1310
+ const leftLeaf = lastNormalizedValue(left);
1311
+ const rightLeaf = lastNormalizedValue(right);
1312
+ return Boolean(leftLeaf && rightLeaf && leftLeaf === rightLeaf);
1313
+ }
1314
+ function sameCategoryPath(left, right) {
1315
+ const leftValues = normalizedFieldValues(left);
1316
+ const rightValues = normalizedFieldValues(right);
1317
+ return (leftValues.length > 0 &&
1318
+ leftValues.length === rightValues.length &&
1319
+ leftValues.every((entry, index) => entry === rightValues[index]));
1320
+ }
1321
+ function elementaryCompartmentKey(value) {
1322
+ const normalized = normalizeText(value);
1323
+ if (!normalized) {
1324
+ return null;
1325
+ }
1326
+ if (/\blow\s*pop\b/u.test(normalized) ||
1327
+ normalized.includes('low population') ||
1328
+ normalized.includes('non urban air') ||
1329
+ normalized.includes('high stacks')) {
1330
+ return 'air_non_urban_or_high_stacks';
1331
+ }
1332
+ if (/\bhigh\s*pop\b/u.test(normalized) ||
1333
+ normalized.includes('high population') ||
1334
+ normalized.includes('urban air close to ground')) {
1335
+ return 'air_urban_close_to_ground';
1336
+ }
1337
+ if (normalized.includes('air indoor') || normalized.includes('indoor air')) {
1338
+ return 'air_indoor';
1339
+ }
1340
+ if (normalized.includes('air unspecified long term')) {
1341
+ return 'air_unspecified_long_term';
1342
+ }
1343
+ if (normalized.includes('air unspecified')) {
1344
+ return 'air_unspecified';
1345
+ }
1346
+ if (normalized.includes('fresh water')) {
1347
+ return 'water_fresh';
1348
+ }
1349
+ if (normalized.includes('sea water')) {
1350
+ return 'water_sea';
1351
+ }
1352
+ if (normalized.includes('water unspecified long term')) {
1353
+ return 'water_unspecified_long_term';
1354
+ }
1355
+ if (normalized.includes('water unspecified')) {
1356
+ return 'water_unspecified';
1357
+ }
1358
+ if (normalized.includes('agricultural soil') && !normalized.includes('non agricultural soil')) {
1359
+ return 'soil_agricultural';
1360
+ }
1361
+ if (normalized.includes('non agricultural soil')) {
1362
+ return 'soil_non_agricultural';
1363
+ }
1364
+ if (normalized.includes('soil unspecified')) {
1365
+ return 'soil_unspecified';
1366
+ }
1367
+ return null;
1368
+ }
1369
+ function sameElementaryCompartment(left, right) {
1370
+ const leftKeys = normalizedFieldValues(left)
1371
+ .map(elementaryCompartmentKey)
1372
+ .filter((entry) => Boolean(entry));
1373
+ const rightKeys = normalizedFieldValues(right)
1374
+ .map(elementaryCompartmentKey)
1375
+ .filter((entry) => Boolean(entry));
1376
+ return leftKeys.length > 0 && rightKeys.length > 0 && intersects(leftKeys, rightKeys);
1377
+ }
708
1378
  function sameExchangeSignature(left, right) {
709
1379
  return left.length > 0 && right.length > 0 && left.join('|') === right.join('|');
710
1380
  }
@@ -712,12 +1382,27 @@ function hasEquivalentFlowCore(target, candidate) {
712
1382
  const hasSameType = sameNonEmptyField(target.fields.type_of_dataset, candidate.fields.type_of_dataset);
713
1383
  const hasSameProperty = sameNonEmptyField(target.fields.flow_property, candidate.fields.flow_property);
714
1384
  const hasSameUnit = sameNonEmptyField(target.fields.reference_unit, candidate.fields.reference_unit);
715
- const hasSameCas = sameNonEmptyField(target.fields.cas, candidate.fields.cas);
1385
+ const hasSameCas = sameCasField(target.fields.cas, candidate.fields.cas);
716
1386
  const hasSameCategory = sameNonEmptyField(target.fields.categories, candidate.fields.categories);
1387
+ const hasSameCategoryLeaf = sameCategoryLeaf(target.fields.categories, candidate.fields.categories);
1388
+ const hasSameCategoryPath = sameCategoryPath(target.fields.categories, candidate.fields.categories);
1389
+ const hasEquivalentName = hasEquivalentFlowName(target, candidate);
1390
+ const hasStrongEquivalentName = hasStrongEquivalentFlowName(target, candidate);
1391
+ const isElementary = sameNonEmptyField(target.fields.type_of_dataset, 'Elementary flow') &&
1392
+ sameNonEmptyField(candidate.fields.type_of_dataset, 'Elementary flow');
1393
+ const hasEquivalentElementaryCompartment = isElementary &&
1394
+ sameElementaryCompartment(target.fields.categories, candidate.fields.categories);
1395
+ if (isElementary) {
1396
+ return (hasSameType &&
1397
+ hasSameProperty &&
1398
+ hasStrongEquivalentName &&
1399
+ (hasSameCategoryLeaf || hasSameCategoryPath || hasEquivalentElementaryCompartment) &&
1400
+ (hasSameCas || !normalizedFieldValues(target.fields.cas).length));
1401
+ }
717
1402
  return (hasSameType &&
718
1403
  hasSameProperty &&
719
1404
  hasSameUnit &&
720
- intersects(target.normalized_names, candidate.normalized_names) &&
1405
+ hasEquivalentName &&
721
1406
  (hasSameCas || hasSameCategory));
722
1407
  }
723
1408
  function candidateEvaluation(target, candidate, kind, index) {
@@ -762,6 +1447,22 @@ function candidateEvaluation(target, candidate, kind, index) {
762
1447
  decisionHint = 'manual_review';
763
1448
  }
764
1449
  }
1450
+ const hasEquivalentName = kind === 'flow' && !hasOverlappingName && hasStrongEquivalentFlowName(target, candidate);
1451
+ if (kind === 'flow' && hasEquivalentName) {
1452
+ matchScore += 18;
1453
+ matchReasons.push('equivalent_flow_name');
1454
+ if (!decisionHint) {
1455
+ decisionHint = 'manual_review';
1456
+ }
1457
+ }
1458
+ const hasSimilarName = !hasOverlappingName && !hasEquivalentName && hasSimilarNamePhrase(target, candidate);
1459
+ if (hasSimilarName) {
1460
+ matchScore += 15;
1461
+ matchReasons.push('similar_name_phrase');
1462
+ if (!decisionHint) {
1463
+ decisionHint = 'manual_review';
1464
+ }
1465
+ }
765
1466
  const targetReferenceFields = Object.values(target.fields)
766
1467
  .flat()
767
1468
  .filter((value) => typeof value === 'string')
@@ -777,6 +1478,38 @@ function candidateEvaluation(target, candidate, kind, index) {
777
1478
  matchScore += 10;
778
1479
  matchReasons.push('overlapping_identity_field');
779
1480
  }
1481
+ if (kind === 'flow') {
1482
+ if (sameNonEmptyField(target.fields.type_of_dataset, candidate.fields.type_of_dataset)) {
1483
+ matchScore += 5;
1484
+ matchReasons.push('same_flow_type');
1485
+ }
1486
+ if (sameNonEmptyField(target.fields.flow_property, candidate.fields.flow_property)) {
1487
+ matchScore += 15;
1488
+ matchReasons.push('same_flow_property');
1489
+ }
1490
+ if (sameNonEmptyField(target.fields.reference_unit, candidate.fields.reference_unit)) {
1491
+ matchScore += 10;
1492
+ matchReasons.push('same_reference_unit');
1493
+ }
1494
+ if (sameCasField(target.fields.cas, candidate.fields.cas)) {
1495
+ matchScore += 10;
1496
+ matchReasons.push('same_cas');
1497
+ }
1498
+ if (sameCategoryPath(target.fields.categories, candidate.fields.categories)) {
1499
+ matchScore += 20;
1500
+ matchReasons.push('same_category_path');
1501
+ }
1502
+ else if (sameCategoryLeaf(target.fields.categories, candidate.fields.categories)) {
1503
+ matchScore += 15;
1504
+ matchReasons.push('same_category_leaf');
1505
+ }
1506
+ else if (sameNonEmptyField(target.fields.type_of_dataset, 'Elementary flow') &&
1507
+ sameNonEmptyField(candidate.fields.type_of_dataset, 'Elementary flow') &&
1508
+ sameElementaryCompartment(target.fields.categories, candidate.fields.categories)) {
1509
+ matchScore += 18;
1510
+ matchReasons.push('equivalent_elementary_compartment');
1511
+ }
1512
+ }
780
1513
  if (kind === 'process' &&
781
1514
  decisionHint === 'manual_review' &&
782
1515
  matchReasons.includes('same_exchange_signature') &&
@@ -792,6 +1525,10 @@ function candidateEvaluation(target, candidate, kind, index) {
792
1525
  matchReasons.push('equivalent_flow_core_fields');
793
1526
  decisionHint = 'block_duplicate';
794
1527
  }
1528
+ if (kind === 'flow' && matchScore > 0 && hasConflictingFlowName(target, candidate)) {
1529
+ matchScore = Math.max(1, matchScore - 35);
1530
+ matchReasons.push('conflicting_flow_name');
1531
+ }
795
1532
  const findings = [];
796
1533
  if (decisionHint === 'block_duplicate') {
797
1534
  findings.push({
@@ -823,6 +1560,9 @@ function candidateEvaluation(target, candidate, kind, index) {
823
1560
  id: candidate.id,
824
1561
  version: candidate.version,
825
1562
  state_code: candidate.state_code,
1563
+ names: candidate.names,
1564
+ fields: candidate.fields,
1565
+ exchange_signature: candidate.exchange_signature,
826
1566
  identity_key: candidate.identity_key,
827
1567
  match_score: matchScore,
828
1568
  match_reasons: matchReasons,
@@ -885,6 +1625,15 @@ function chooseDecision(evaluations, validation, kind) {
885
1625
  findings,
886
1626
  };
887
1627
  }
1628
+ function sortedEvaluations(evaluations) {
1629
+ return [...evaluations].sort((left, right) => {
1630
+ const scoreDiff = right.report.match_score - left.report.match_score;
1631
+ if (scoreDiff !== 0) {
1632
+ return scoreDiff;
1633
+ }
1634
+ return left.report.index - right.report.index;
1635
+ });
1636
+ }
888
1637
  function statusForDecision(decision, blockers) {
889
1638
  if (blockers.length > 0 || decision === 'block_duplicate') {
890
1639
  return 'blocked';
@@ -934,8 +1683,8 @@ function writeArtifacts(report, outDir) {
934
1683
  export async function runIdentityPreflight(kind, options) {
935
1684
  const inputPath = requiredInputPath(options.inputPath);
936
1685
  const normalizedInput = normalizePreflightInput(options.rawInput ?? readJsonInput(inputPath), kind);
937
- const targetProfile = profileForKind(normalizedInput.target, kind);
938
1686
  const remoteCandidateSearch = mergeRemoteCandidateSearchConfig(normalizedInput.remoteCandidateSearch, options);
1687
+ const targetProfile = applyIdentityProfileHints(profileForKind(normalizedInput.target, kind), remoteCandidateSearch.profileHints, kind);
939
1688
  const candidateSourceReads = [
940
1689
  ...normalizedInput.candidateInputPaths,
941
1690
  ...(options.candidateInputPaths ?? []),
@@ -976,11 +1725,13 @@ export async function runIdentityPreflight(kind, options) {
976
1725
  target: {
977
1726
  id: targetProfile.id,
978
1727
  version: targetProfile.version,
1728
+ names: targetProfile.names,
1729
+ fields: targetProfile.fields,
979
1730
  identity_key: targetProfile.identity_key,
980
1731
  exchange_signature: targetProfile.exchange_signature,
981
1732
  schema_validation: validation,
982
1733
  },
983
- candidates: evaluations.map((evaluation) => evaluation.report),
1734
+ candidates: sortedEvaluations(evaluations).map((evaluation) => evaluation.report),
984
1735
  candidate_sources: candidateSources,
985
1736
  findings: decision.findings,
986
1737
  blockers,
@@ -1004,18 +1755,45 @@ export async function runFlowIdentityPreflight(options) {
1004
1755
  return (await runIdentityPreflight('flow', options));
1005
1756
  }
1006
1757
  export const __testInternals = {
1007
- normalizePreflightInput,
1008
- schemaForKind,
1009
- entityFactoryExports: ENTITY_FACTORY_EXPORTS,
1010
- processProfile,
1011
- flowProfile,
1758
+ appendQueryLine,
1759
+ applyIdentityProfileHints,
1012
1760
  candidateEvaluation,
1013
1761
  chooseDecision,
1014
1762
  collectCandidateFilesFromStats,
1015
- readCandidateSource,
1763
+ compactRemoteQuery,
1016
1764
  defaultRemoteQuery,
1765
+ elementaryCompartmentKey,
1766
+ entityFactoryExports: ENTITY_FACTORY_EXPORTS,
1767
+ exchangeFlowRefsFromSignature,
1768
+ expandedFlowNameVariants,
1769
+ flowNameVariants,
1770
+ flowProfile,
1771
+ hasConflictingFlowName,
1772
+ identityKeyFromProfile,
1773
+ isRemoteQueryNoiseText,
1774
+ mergeRemoteCandidateSearchConfig,
1775
+ nameTokens,
1776
+ nextActionForDecision,
1777
+ normalizeMatchThreshold,
1778
+ normalizeNonNegativeNumber,
1779
+ normalizePreflightInput,
1017
1780
  normalizeRemoteCandidateSearch,
1781
+ normalizePositiveInteger,
1782
+ processProfile,
1783
+ processReferenceExchange,
1784
+ processReferenceFlowValues,
1785
+ profileForKind,
1786
+ queryFieldValues,
1787
+ readCandidateSource,
1788
+ remoteSearchOptions,
1018
1789
  remoteSearchFilter,
1019
1790
  rowsFromRemoteSearchResponse,
1791
+ sameCasField,
1792
+ sameElementaryCompartment,
1793
+ sameNonEmptyField,
1794
+ schemaForKind,
1795
+ statusForDecision,
1796
+ tokenCoverageRatio,
1797
+ tokenOverlapRatio,
1020
1798
  };
1021
1799
  //# sourceMappingURL=identity-preflight.js.map