ATLAS Offline Software
Loading...
Searching...
No Matches
GeneratorSettingsSemantics.py
Go to the documentation of this file.
1# Copyright (C) 2002-2026 CERN for the benefit of the ATLAS collaboration
2
3from enum import Enum, IntEnum
4import re
5
6from GaudiConfig2.semantics import getSemanticsFor, SequenceSemantics
7from AthenaCommon.Logging import logging
8
9genSettingsLog = logging.getLogger("GeneratorSettingsSemantics")
10
11
12class GeneratorSettingsKeep(str, Enum):
13 """
14 Which duplicate setting survives after layers are sorted by precedence.
15
16 Layers are resolved in increasing precedence order: BASE, then TUNE, then
17 USER. A duplicate is either a parsed command with the same normalized key,
18 or an unparsed command with the same normalized full text.
19
20 FIRST keeps the first duplicate encountered. This preserves lower-precedence
21 defaults when later layers repeat the same setting.
22
23 LAST keeps the last duplicate encountered. This is the normal generator
24 behavior: tune settings override base settings, and user settings override
25 both.
26 """
27 FIRST = "first"
28 LAST = "last"
29
30
31class GeneratorSettingsRecord(str, Enum):
32 """
33 How one command string is understood during merging.
34
35 Each command is parsed with the separators from GeneratorSettingsLayer.
36 Commands that contain one of those separators are treated as parsed
37 key/value commands, e.g. "Main:timesAllowErrors = 500". These can override
38 each other by key.
39
40 Commands without any configured separator are unparsed commands. They have
41 no key, so they can only be deduplicated when their normalized full text is
42 identical.
43 """
44 PARSED_COMMAND = "parsed_command"
45 UNPARSED_COMMAND = "unparsed_command"
46
47
49 """
50 Precedence levels for generator settings layers.
51 Lower precedence layers are overridden by higher ones during merging.
52 """
53 BASE = 10
54 TUNE = 20
55 MATCHING = 30
56 WEIGHTS = 40
57 USER = 100
58
59
61 """
62 A layer contains all generator settings from a single source,
63 e.g. a user fragment, tune, etc.
64
65 Normal assignments still use a plain sequence of strings. Layers are only
66 needed by configuration fragments that want to describe where commands came
67 from and how they should be ordered during CA merging.
68 """
69
70 def __init__(self,
71 source,
72 values,
73 precedence,
74 separators="=",
75 keep=GeneratorSettingsKeep.LAST,
76 report_context=None):
77 if isinstance(values, str):
78 raise TypeError("GeneratorSettingsLayer values must be a sequence")
79
80 # Label used in conflict/duplicate reports
81 self.source = str(source)
82
83 # Generator command strings belonging to this layer
84 self.values = tuple(values or ())
85
86 # Precedence used for deduplication and conflict resolution during merging
88
89 # Separators used to parse a generator setting command into a key/value
90 # pair for duplicate detection
91 if isinstance(separators, str):
92 separators = [separators]
93 self.separators = tuple(separators)
94
95 # Which matching key wins after sorting by layer precedence
97
98 # Text that should be mentioned in the deduplication report,
99 # usually the property name from the fragment.
100 self.report_context = report_context
101
102
104 """
105 Stored property value used by GeneratorSettingsSemantics.
106
107 It keeps the unresolved layers rather than a pre-merged list so that
108 CA merges remain associative: base.merge(user) and user.merge(base)
109 yield the same result.
110 """
111 def __init__(self, layers=None):
112 self.layers = []
114 seen = set()
115
116 for layer in layers or ():
117 if not isinstance(layer, GeneratorSettingsLayer):
118 layer_type = type(layer).__name__
119 raise TypeError(
120 f"GeneratorSettingsValue layers must be "
121 f"GeneratorSettingsLayer instances, got {layer_type}"
122 )
123
124 # Deduplicate identical layers explicitly instead of relying on
125 # object identity. This is what makes repeated symmetric CA merges
126 # yield the same result.
127 key = (
128 layer.source,
129 layer.values,
130 layer.precedence,
131 layer.separators,
132 layer.keep,
133 layer.report_context,
134 )
135 if key not in seen:
136 self.layers.append(layer)
137 seen.add(key)
138
139 @property
140 def data(self):
141 return self.resolve()
142
143 def resolve(self, report_context=None, emit_report=False):
144 """
145 Resolve all layers to the final settings list.
146
147 If emit_report is True, print duplicate/conflict warnings once using
148 report_context (or a layer-provided report_context if available).
149 """
150 settings, report = self._resolve()
151
152 if emit_report:
153 for settings_layer in _ordered_layers(self.layers):
154 if settings_layer.report_context:
155 report_context = settings_layer.report_context
156 break
157
158 if report_context and report_context not in self._reported_contexts:
159 _log_report(report_context, report)
160 self._reported_contexts.add(report_context)
161
162 return settings
163
164 def __str__(self):
165 """
166 Return the resolved list as a string.
167 ComponentAccumulator.gatherProps() converts stored property values
168 to strings before handing them to Gaudi.
169 """
170 return str(self.resolve(emit_report=True))
171
172 def _resolve(self):
173 """Return the final command list plus the duplicate/conflict report."""
174 layers = _ordered_layers(self.layers)
175 separators = layers[0].separators if layers else ("=",)
176 keep = layers[0].keep if layers else GeneratorSettingsKeep.LAST
177 precedences_seen = {}
178 for layer in layers:
179 if (
180 layer.separators == separators
181 and layer.keep == keep
182 ):
183 if layer.precedence in precedences_seen:
184 previous_layer = precedences_seen[layer.precedence]
185 raise ValueError(
186 f"cannot merge generator settings with duplicate "
187 f"precedence {layer.precedence.name}: "
188 f"{previous_layer.source} and {layer.source}"
189 )
190 precedences_seen[layer.precedence] = layer
191 continue
192
193 summary = ", ".join(
194 f"{entry.source}: separators={entry.separators}, "
195 f"keep={entry.keep.value}"
196 for entry in layers
197 )
198 raise ValueError(
199 f"cannot merge generator settings with different parsing "
200 f"settings: "
201 f"{summary}"
202 )
203
204 records = _build_records(layers, separators)
205 kept_records, removed_records = _deduplicate_records(records, keep)
206 return (
207 [record["original_setting"] for record in kept_records],
208 _build_report(records, kept_records, removed_records),
209 )
210
211
212class GeneratorSettingsSemantics(SequenceSemantics):
213 """
214 Semantics for generator settings properties.
215
216 Gaudi sees the property as a sequence of strings, but CA merging receives
217 GeneratorSettingsLayer objects so fragments can be combined by precedence
218 before the final list is handed to the C++ component.
219 """
220 __handled_types__ = [re.compile(r"^GeneratorSettings<.*>$")]
221
222 def __init__(self, cpp_type):
223 # SequenceSemantics needs the element semantics even though we store a
224 # custom helper value instead of Gaudi's normal list helper.
225 super().__init__(cpp_type, valueSem=getSemanticsFor("std::string"))
226
227 def store(self, assigned_value):
228 """Convert user input into the internal unresolved layer container."""
229 if isinstance(assigned_value, GeneratorSettingsValue):
230 return assigned_value
231 if isinstance(assigned_value, GeneratorSettingsLayer):
232 return GeneratorSettingsValue([assigned_value])
233 if isinstance(assigned_value, str):
234 raise TypeError("generator settings must be assigned from a sequence")
237 source=self.name or "<unknown>",
238 values=tuple(assigned_value or ()),
239 precedence=GeneratorSettingsPrecedence.USER,
240 report_context=self.name,
241 )
242 ])
243
244 def default(self, default_commands):
245 """Store C++ defaults as the lowest-precedence command layer."""
246 if not default_commands:
248 if isinstance(default_commands, str):
249 raise TypeError("generator settings must be assigned from a sequence")
252 source=self.name or "<default>",
253 values=tuple(default_commands or ()),
254 precedence=GeneratorSettingsPrecedence.BASE,
255 report_context=self.name,
256 )
257 ])
258
259 def merge(self, current_value, incoming_value):
260 """Merge two unresolved layer sets during CA merging."""
261 current_settings = self.store(current_value)
262 incoming_settings = self.store(incoming_value)
264 current_settings.layers + incoming_settings.layers
265 )
266
267 def opt_value(self, stored_value):
268 """
269 Return the final plain command list.
270 This is the point where all layers are resolved.
271 Print duplicate and conflict warnings here.
272 """
273 settings_value = self.store(stored_value)
274 return settings_value.resolve(report_context=self.name, emit_report=True)
275
276
277def _ordered_layers(layers):
278 """Apply precedence before deduplication."""
279 return sorted(
280 layers,
281 key=lambda layer: (
282 int(layer.precedence),
283 layer.source,
284 repr(layer.values),
285 ),
286 )
287
288
289def _build_records(layers, separators):
290 """
291 Convert raw command strings into normalized records.
292 Parsed commands are deduplicated by key. Commands that cannot be parsed as
293 key/value records are deduplicated by their normalized full text.
294 """
295 records = []
296 for layer in layers:
297 for raw_setting in layer.values:
298 key_text, value_text = _parse_assignment(raw_setting, separators)
299 if key_text is None:
300 records.append({
301 "source_name": layer.source,
302 "record_kind": GeneratorSettingsRecord.UNPARSED_COMMAND,
303 "normalized_key": None,
304 "normalized_value": None,
305 "original_setting": raw_setting,
306 "dedup_signature": (
307 GeneratorSettingsRecord.UNPARSED_COMMAND,
308 _normalize_text(raw_setting),
309 ),
310 })
311 continue
312
313 normalized_key = _normalize_text(key_text)
314 records.append({
315 "source_name": layer.source,
316 "record_kind": GeneratorSettingsRecord.PARSED_COMMAND,
317 "normalized_key": normalized_key,
318 "normalized_value": _normalize_text(value_text),
319 "original_setting": raw_setting,
320 "dedup_signature": (
321 GeneratorSettingsRecord.PARSED_COMMAND,
322 normalized_key,
323 ),
324 })
325 return records
326
327
328def _parse_assignment(setting_text, separators):
329 text = str(setting_text).strip()
330 for separator in separators:
331 if separator in text:
332 key_text, value_text = text.split(separator, 1)
333 return key_text.strip(), value_text.strip()
334 return None, None
335
336
338 text = str(value).strip()
339 return " ".join(text.split())
340
341
342def _deduplicate_records(records, keep):
343 """Keep the first or last record for each normalized setting key."""
344 keep_last = keep == GeneratorSettingsKeep.LAST
345 records_to_scan = reversed(records) if keep_last else records
346
347 kept_records = []
348 removed_records = []
349 first_seen_by_signature = {}
350 for record in records_to_scan:
351 signature = record["dedup_signature"]
352 if signature in first_seen_by_signature:
353 kept_record = first_seen_by_signature[signature]
354 removed_record = dict(record)
355 removed_record["duplicate_of_source_name"] = kept_record["source_name"]
356 removed_record["kept_normalized_value"] = kept_record["normalized_value"]
357 removed_record["kept_original_setting"] = kept_record["original_setting"]
358 removed_records.append(removed_record)
359 continue
360
361 first_seen_by_signature[signature] = record
362 kept_records.append(record)
363
364 if keep_last:
365 kept_records.reverse()
366 removed_records.reverse()
367 return kept_records, removed_records
368
369
370def _build_report(records, kept_records, removed_records):
371 """Collect duplicate and conflict information for logging/tests."""
372 removed_duplicates = [
373 {
374 "source": record["source_name"],
375 "duplicate_of_source": record.get("duplicate_of_source_name"),
376 "setting": record["original_setting"],
377 "kept_setting": record.get("kept_original_setting"),
378 "normalized_value": record["normalized_value"],
379 "kept_normalized_value": record.get("kept_normalized_value"),
380 }
381 for record in removed_records
382 ]
383
384 removed_identical = [
385 {
386 "source": record["source"],
387 "duplicate_of_source": record.get("duplicate_of_source"),
388 "setting": record["setting"],
389 "kept_setting": record.get("kept_setting"),
390 }
391 for record in removed_duplicates
392 if record["normalized_value"] == record.get("kept_normalized_value")
393 ]
394 duplicates_in_source = {}
395 duplicates_across_sources = {}
396
397 for record in removed_identical:
398 source_name = record.get("source", "<unknown>")
399 duplicate_of_source = record.get("duplicate_of_source", "<unknown>")
400 if duplicate_of_source == source_name:
401 duplicates_in_source[source_name] = (
402 duplicates_in_source.get(source_name, 0) + 1
403 )
404 continue
405
406 source_pair = (source_name, duplicate_of_source)
407 duplicates_across_sources[source_pair] = (
408 duplicates_across_sources.get(source_pair, 0) + 1
409 )
410
411 conflict_details = _build_conflict_details(records, kept_records)
412
413 return {
414 "removed_duplicates": removed_duplicates,
415 "removed_identical": removed_identical,
416 "conflicting_reassignments": [
417 record
418 for record in removed_duplicates
419 if record["normalized_value"] != record.get("kept_normalized_value")
420 ],
421 "removed_overridden": [
422 record
423 for record in removed_duplicates
424 if record["normalized_value"] != record.get("kept_normalized_value")
425 ],
426 "conflict_details": conflict_details,
427 "conflicts": _find_conflicts(records),
428 "duplicates_in_source": duplicates_in_source,
429 "duplicates_across_sources": [
430 {
431 "source": source_name,
432 "duplicate_of_source": duplicate_of_source,
433 "count": duplicate_count,
434 }
435 for (source_name, duplicate_of_source), duplicate_count
436 in sorted(duplicates_across_sources.items())
437 ],
438 }
439
440
441def _build_conflict_details(records, kept_records):
442 """
443 Build one reporting entry per conflicting parsed key.
444
445 The source and value order follows the original record order, and the kept
446 value is marked explicitly in the value list.
447 """
448 kept_value_by_key = {}
449 for record in kept_records:
450 if record["record_kind"] != GeneratorSettingsRecord.PARSED_COMMAND:
451 continue
452 kept_value_by_key[record["normalized_key"]] = record["normalized_value"]
453
454 values_by_key = {}
455 for record in records:
456 if record["record_kind"] != GeneratorSettingsRecord.PARSED_COMMAND:
457 continue
458
459 key = record["normalized_key"]
460 source_name = record["source_name"]
461 value = record["normalized_value"]
462
463 key_entry = values_by_key.setdefault(
464 key,
465 {
466 "sources": [],
467 "source_set": set(),
468 "values": [],
469 "value_set": set(),
470 "source_to_values": {},
471 },
472 )
473 if source_name not in key_entry["source_set"]:
474 key_entry["source_set"].add(source_name)
475 key_entry["sources"].append(source_name)
476 if value not in key_entry["value_set"]:
477 key_entry["value_set"].add(value)
478 key_entry["values"].append(value)
479 key_entry["source_to_values"].setdefault(source_name, set()).add(value)
480
481 conflict_details = []
482 for key, key_entry in values_by_key.items():
483 merged_values = set()
484 for values in key_entry["source_to_values"].values():
485 merged_values.update(values)
486 if len(merged_values) <= 1 or len(key_entry["source_to_values"]) <= 1:
487 continue
488
489 kept_value = kept_value_by_key.get(key)
490 marked_values = []
491 for value in key_entry["values"]:
492 if value == kept_value:
493 marked_values.append(f"{value} (kept)")
494 continue
495 marked_values.append(value)
496
497 conflict_details.append({
498 "key": key,
499 "sources": key_entry["sources"],
500 "values": marked_values,
501 })
502 return conflict_details
503
504
505def _find_conflicts(records):
506 """Find keys assigned to multiple values within or across sources."""
507 values_by_key_and_source = {}
508 for record in records:
509 if record["record_kind"] != GeneratorSettingsRecord.PARSED_COMMAND:
510 continue
511
512 key = record["normalized_key"]
513 source_name = record["source_name"]
514 value = record["normalized_value"]
515 values_by_key_and_source.setdefault(key, {}).setdefault(
516 source_name,
517 set(),
518 ).add(value)
519
520 conflicts = []
521 for key, source_to_values in values_by_key_and_source.items():
522 for source_name, values in source_to_values.items():
523 if len(values) > 1:
524 conflicts.append({
525 "type": "intra_source_conflict",
526 "key": key,
527 "source": source_name,
528 "values": sorted(values),
529 })
530
531 merged_values = set()
532 for values in source_to_values.values():
533 merged_values.update(values)
534 if len(merged_values) > 1 and len(source_to_values) > 1:
535 conflicts.append({
536 "type": "inter_source_conflict",
537 "key": key,
538 "sources": sorted(source_to_values.keys()),
539 "values": sorted(merged_values),
540 })
541 return conflicts
542
543
544def _log_report(context, report):
545 """Print warnings from the structured report."""
546 issue_prefix = "Potential issue with generator settings"
547
548 duplicates = report.get("removed_identical", [])
549 conflict_details = report.get("conflict_details", [])
550 if not duplicates and not conflict_details:
551 return
552
553 genSettingsLog.warning(
554 f"{issue_prefix} [{context}]: found {len(duplicates)} duplicate "
555 f"setting(s) across sources and {len(conflict_details)} conflicting "
556 f"setting key(s)"
557 )
558
559 for entry in sorted(
560 duplicates,
561 key=lambda record: (
562 record.get("source", ""),
563 record.get("duplicate_of_source", ""),
564 record.get("setting", ""),
565 ),
566 ):
567 source_name = entry.get("source", "<unknown>")
568 duplicate_of_source = entry.get("duplicate_of_source", "<unknown>")
569 setting = entry.get("setting", "<unknown>")
570 source_list = [source_name]
571 if duplicate_of_source != source_name:
572 source_list.append(duplicate_of_source)
573 genSettingsLog.warning(
574 f"{issue_prefix} [{context}]: duplicate setting from sources "
575 f"[{', '.join(source_list)}]: {setting}"
576 )
577
578 for entry in conflict_details:
579 key = entry.get("key", "<unknown>")
580 sources = ", ".join(entry.get("sources", []))
581 values = ", ".join(entry.get("values", []))
582 genSettingsLog.warning(
583 f"{issue_prefix} [{context}]: conflicting setting '{key}' across "
584 f"sources [{sources}] -> [{values}]"
585 )
__init__(self, source, values, precedence, separators="=", keep=GeneratorSettingsKeep.LAST, report_context=None)
STL class.
bool add(const std::string &hname, TKey *tobj)
Definition fastadd.cxx:55
Definition merge.py:1
_build_report(records, kept_records, removed_records)