Skip to content

Commit a2e21be

Browse files
committed
feat(styles): field-merge runtime scope cascade
The runtime scope cascade (global -> citation/bibliography options) replaced nested option blocks whole-value, forcing styles like gb-t-7714-2025-base to duplicate their global dates block at bibliography scope just to add note-wrap. Capture each parsed style's authored scope-level options mappings (presets expanded) in Style.scoped_raw_options, chain-merge them through extends resolution, and merge nested blocks field-by-field at cascade time via merged_with_raw, guarded by the overlay's typed round-trip check with fallback to the typed whole-block merge. The GB/T bibliography dates block collapses to note-wrap only, with byte-identical rendered output across the embedded and in-repo corpus (48 styles, bibliography and citation modes, rendered directly). Spec: docs/specs/UNIFIED_SCOPED_OPTIONS.md section 2a (csl26-yz4w).
1 parent 7f8d014 commit a2e21be

13 files changed

Lines changed: 670 additions & 40 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
# csl26-yz4w
3+
title: Field-level merge for runtime scoped-options cascade
4+
status: completed
5+
type: feature
6+
priority: normal
7+
tags:
8+
- architecture
9+
- styles
10+
- schema
11+
created_at: 2026-07-28T15:55:45Z
12+
updated_at: 2026-07-30T11:44:44Z
13+
parent: csl26-s2rw
14+
---
15+
16+
The extends overlay now deep-merges nested option blocks (STYLE_INHERITANCE.md rule 1), but the runtime scope cascade (global -> citation/bibliography via Config::merge / merge_options! in crates/citum-schema-style/src/options/mod.rs) still replaces nested structs whole-value. Consequence: gb-t-7714-2025-base.yaml must keep a full bibliography.options.dates copy of its global dates block just to add note-wrap at bibliography scope. Design question: runtime merge has no raw YAML, so field-presence is ambiguous for defaulted non-Option fields (deserialized defaults are indistinguishable from authored defaults). Candidate approaches: presence-tracking wrapper types, keep raw per-scope mappings on Config, or an authored-value != default heuristic. Owned by UNIFIED_SCOPED_OPTIONS.md; STYLE_INHERITANCE.md deliberately scopes this out.
17+
18+
## Summary of Changes
19+
20+
Implemented the raw per-scope-mapping design (candidate 2). Each parsed style
21+
captures its authored citation/bibliography `options` mappings (presets
22+
expanded) in a new `Style.scoped_raw_options` field; `extends` resolution
23+
chain-merges captures alongside the typed overlay. New
24+
`CitationOptions::merged_with_raw` / `BibliographyOptions::merged_with_raw`
25+
merge nested blocks field-by-field from the capture, guarded by the same
26+
typed round-trip check as the overlay, falling back to the typed whole-block
27+
merge (programmatic construction, post-parse mutation). Engine
28+
`get_citation_config`/`get_bibliography_config` use the raw-aware path;
29+
lint and citum-migrate SQI refinement intentionally stay typed.
30+
31+
Rejected alternatives: eager materialization at load time (freezes inherited
32+
global fields into scope blocks, breaking propagation through wrapper
33+
chains — covered by a regression test), presence-wrapper types (churn),
34+
authored≠default heuristic (unsound).
35+
36+
Verified: gb-t-7714-2025-base's duplicated bibliography `dates` block
37+
reduced to just `note-wrap`; full corpus render diff vs main (48 styles ×
38+
bib+cite modes, direct `citum render refs`) is byte-identical. Spec:
39+
UNIFIED_SCOPED_OPTIONS.md §2a; cross-ref in STYLE_INHERITANCE.md.

.beans/csl26-yz4w--field-level-merge-for-runtime-scoped-options-casca.md

Lines changed: 0 additions & 16 deletions
This file was deleted.

crates/citum-engine/src/processor/setup.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,10 @@ impl Processor {
512512
.as_ref()
513513
.and_then(|citation| citation.options.as_ref())
514514
{
515-
Some(citation_options) => std::borrow::Cow::Owned(citation_options.merged_with(base)),
515+
Some(citation_options) => std::borrow::Cow::Owned(
516+
citation_options
517+
.merged_with_raw(base, self.style.scoped_raw_options.citation.as_ref()),
518+
),
516519
None => std::borrow::Cow::Borrowed(base),
517520
};
518521
self.with_punctuation_defaults(config)
@@ -530,9 +533,10 @@ impl Processor {
530533
.as_ref()
531534
.and_then(|bibliography| bibliography.options.as_ref())
532535
{
533-
Some(bibliography_options) => {
534-
std::borrow::Cow::Owned(bibliography_options.merged_with(base))
535-
}
536+
Some(bibliography_options) => std::borrow::Cow::Owned(
537+
bibliography_options
538+
.merged_with_raw(base, self.style.scoped_raw_options.bibliography.as_ref()),
539+
),
536540
None => std::borrow::Cow::Borrowed(base),
537541
};
538542
self.with_punctuation_defaults(config)

crates/citum-schema-style/embedded/styles/gb-t-7714-2025-base.yaml

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -103,22 +103,11 @@ options:
103103
bibliography:
104104
sort: citation-number
105105
options:
106-
# The runtime scope cascade replaces the global `dates` block whole at
107-
# bibliography scope (no field-level merge yet — csl26-yz4w), so this
108-
# block repeats the global values alongside the bibliography-only
109-
# `note-wrap` opt-in shared by all GB/T leaf styles — see
110-
# CALENDAR_DATE_ANNOTATIONS.md.
106+
# The runtime scope cascade merges nested blocks field-by-field
107+
# (csl26-yz4w), so this block only opts into the bibliography-only
108+
# `note-wrap` shared by all GB/T leaf styles — every other dates field
109+
# inherits from the global block. See CALENDAR_DATE_ANNOTATIONS.md.
111110
dates:
112-
month: numeric
113-
uncertainty-marker: '?'
114-
approximation-marker: '['
115-
approximation-marker-suffix: ']'
116-
range-delimiter:
117-
no-date-year-suffix-delimiter: '-'
118-
show-seconds: false
119-
show-timezone: false
120-
era-labels: default
121-
negative-unspecified-years: range
122111
note-wrap: parentheses
123112
contributors:
124113
shorten:
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/*
2+
SPDX-License-Identifier: MIT OR Apache-2.0
3+
SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4+
*/
5+
6+
//! Field-level merge support for the runtime scoped-options cascade.
7+
//!
8+
//! The `extends` overlay deep-merges nested option blocks using authored
9+
//! raw-document presence (`docs/specs/STYLE_INHERITANCE.md` rule 1). The
10+
//! runtime scope cascade (global → citation/bibliography options) has the
11+
//! same authored-vs-default ambiguity: a scope block that sets one field of
12+
//! `dates` deserializes with serde defaults for every other field, so a typed
13+
//! struct merge cannot tell which fields the author wrote and must replace
14+
//! the whole block. This module carries the authored scope-level `options`
15+
//! mappings through resolution — chain-merged across `extends` — so the
16+
//! cascade can merge nested blocks field-by-field, and falls back to the
17+
//! typed whole-block merge whenever no trustworthy raw mapping is available.
18+
19+
use serde::Serialize;
20+
use serde::de::DeserializeOwned;
21+
22+
use crate::Style;
23+
use crate::style::overlay::{deep_merge_yaml_value, effective_overlay_options};
24+
25+
use super::Config;
26+
27+
/// Chain-merged authored `options` mappings for the citation and bibliography
28+
/// scopes of a style.
29+
///
30+
/// Populated when a style is parsed from a document (preset names expanded to
31+
/// their resolved mappings) and maintained through `extends` resolution by
32+
/// deep-merging each child's authored scope mapping over the parent's. `None`
33+
/// for a scope means no trustworthy authored mapping is available
34+
/// (programmatic construction, explicit clearing, or a non-mapping value);
35+
/// the runtime cascade then falls back to the typed whole-block merge.
36+
#[derive(Debug, Default, Clone)]
37+
pub struct ScopedRawOptions {
38+
/// Authored `citation.options` mapping, presets expanded, chain-merged.
39+
pub citation: Option<serde_yaml::Value>,
40+
/// Authored `bibliography.options` mapping, presets expanded, chain-merged.
41+
pub bibliography: Option<serde_yaml::Value>,
42+
}
43+
44+
impl ScopedRawOptions {
45+
/// Capture the authored scope mappings of a freshly parsed style document.
46+
pub(crate) fn capture(style: &Style) -> Self {
47+
Self::default().merged_with_child(style)
48+
}
49+
50+
/// Overlay a child's authored scope mappings onto this (parent) capture.
51+
///
52+
/// A child without `raw_yaml` (programmatic construction) has an unknown
53+
/// authored key-set, so both scopes reset to `None` and the runtime
54+
/// cascade falls back to the typed merge — mirroring the overlay's own
55+
/// raw-path fallback.
56+
pub(crate) fn merged_with_child(self, child: &Style) -> Self {
57+
let Some(raw) = child.raw_yaml.as_ref() else {
58+
return Self::default();
59+
};
60+
Self {
61+
citation: merge_scope(
62+
self.citation,
63+
authored_scope_options(
64+
raw,
65+
"citation",
66+
child.citation.as_ref().and_then(|c| c.options.as_ref()),
67+
),
68+
),
69+
bibliography: merge_scope(
70+
self.bibliography,
71+
authored_scope_options(
72+
raw,
73+
"bibliography",
74+
child.bibliography.as_ref().and_then(|b| b.options.as_ref()),
75+
),
76+
),
77+
}
78+
}
79+
}
80+
81+
/// Authored state of one scope's `options` mapping in a raw style document.
82+
enum AuthoredScope {
83+
/// The scope section or its `options` key is absent: inherit the parent's.
84+
Inherit,
85+
/// The `options` key is explicitly null, non-mapping, or inconsistent with
86+
/// the typed value: no presence information survives.
87+
Clear,
88+
/// The authored `options` mapping, preset-name strings expanded to their
89+
/// resolved mappings via the typed scope options.
90+
Authored(serde_yaml::Value),
91+
}
92+
93+
/// Extract one scope's authored `options` mapping from a raw style document,
94+
/// expanding preset-name strings through the typed scope options so stored
95+
/// mappings layer like authored blocks (STYLE_INHERITANCE.md rule 3).
96+
fn authored_scope_options<T: Serialize>(
97+
raw_doc: &serde_yaml::Value,
98+
section: &str,
99+
typed: Option<&T>,
100+
) -> AuthoredScope {
101+
let Some(section_value) = raw_doc.get(section) else {
102+
return AuthoredScope::Inherit;
103+
};
104+
if section_value.is_null() {
105+
return AuthoredScope::Clear;
106+
}
107+
let Some(options_value) = section_value.get("options") else {
108+
return AuthoredScope::Inherit;
109+
};
110+
if !options_value.is_mapping() {
111+
return AuthoredScope::Clear;
112+
}
113+
let Some(typed) = typed else {
114+
return AuthoredScope::Clear;
115+
};
116+
match serde_yaml::to_value(typed) {
117+
Ok(typed_value) => {
118+
AuthoredScope::Authored(effective_overlay_options(options_value, &typed_value))
119+
}
120+
Err(_) => AuthoredScope::Clear,
121+
}
122+
}
123+
124+
/// Merge a child's authored scope mapping over the parent's chain capture.
125+
fn merge_scope(
126+
parent: Option<serde_yaml::Value>,
127+
child: AuthoredScope,
128+
) -> Option<serde_yaml::Value> {
129+
match child {
130+
AuthoredScope::Inherit => parent,
131+
AuthoredScope::Clear => None,
132+
AuthoredScope::Authored(child_value) => match parent {
133+
Some(mut merged) if merged.is_mapping() => {
134+
deep_merge_yaml_value(&mut merged, &child_value);
135+
Some(merged)
136+
}
137+
_ => Some(child_value),
138+
},
139+
}
140+
}
141+
142+
/// True when `raw` is a mapping that faithfully round-trips to `typed` — the
143+
/// post-parse mutation guard of STYLE_INHERITANCE.md applied at cascade time.
144+
/// Styles mutated programmatically after parse carry stale captures; the
145+
/// guard makes the cascade fall back to the typed merge for them.
146+
pub(crate) fn authored_matches<T>(raw: &serde_yaml::Value, typed: &T) -> bool
147+
where
148+
T: DeserializeOwned + PartialEq,
149+
{
150+
raw.is_mapping()
151+
&& serde_yaml::from_value::<T>(raw.clone()).is_ok_and(|authored| authored == *typed)
152+
}
153+
154+
/// Re-merge the nested option blocks a citation scope can override,
155+
/// field-by-field from the authored raw scope mapping, with the resolved
156+
/// global `base` supplying every field the scope author did not write.
157+
pub(crate) fn merge_citation_config_blocks_from_raw(
158+
merged: &mut Config,
159+
base: &Config,
160+
raw: &serde_yaml::Value,
161+
) {
162+
merge_block(
163+
&mut merged.substitute,
164+
base.substitute.as_ref(),
165+
raw.get("substitute"),
166+
);
167+
merge_block(
168+
&mut merged.multilingual,
169+
base.multilingual.as_ref(),
170+
raw.get("multilingual"),
171+
);
172+
merge_block(
173+
&mut merged.contributors,
174+
base.contributors.as_ref(),
175+
raw.get("contributors"),
176+
);
177+
merge_block(&mut merged.dates, base.dates.as_ref(), raw.get("dates"));
178+
merge_block(&mut merged.titles, base.titles.as_ref(), raw.get("titles"));
179+
merge_block(
180+
&mut merged.locators,
181+
base.locators.as_ref(),
182+
raw.get("locators"),
183+
);
184+
merge_block(&mut merged.links, base.links.as_ref(), raw.get("links"));
185+
merge_block(&mut merged.notes, base.notes.as_ref(), raw.get("notes"));
186+
merge_block(
187+
&mut merged.integral_name_memory,
188+
base.integral_name_memory.as_ref(),
189+
raw.get("integral-name-memory"),
190+
);
191+
merge_block(
192+
&mut merged.org_abbreviation_memory,
193+
base.org_abbreviation_memory.as_ref(),
194+
raw.get("org-abbreviation-memory"),
195+
);
196+
}
197+
198+
/// Re-merge the nested option blocks a bibliography scope can override,
199+
/// field-by-field from the authored raw scope mapping, with the resolved
200+
/// global `base` supplying every field the scope author did not write.
201+
pub(crate) fn merge_bibliography_config_blocks_from_raw(
202+
merged: &mut Config,
203+
base: &Config,
204+
raw: &serde_yaml::Value,
205+
) {
206+
merge_block(
207+
&mut merged.substitute,
208+
base.substitute.as_ref(),
209+
raw.get("substitute"),
210+
);
211+
merge_block(
212+
&mut merged.multilingual,
213+
base.multilingual.as_ref(),
214+
raw.get("multilingual"),
215+
);
216+
merge_block(
217+
&mut merged.sorting,
218+
base.sorting.as_ref(),
219+
raw.get("sorting"),
220+
);
221+
merge_block(
222+
&mut merged.contributors,
223+
base.contributors.as_ref(),
224+
raw.get("contributors"),
225+
);
226+
merge_block(&mut merged.dates, base.dates.as_ref(), raw.get("dates"));
227+
merge_block(&mut merged.titles, base.titles.as_ref(), raw.get("titles"));
228+
merge_block(&mut merged.links, base.links.as_ref(), raw.get("links"));
229+
}
230+
231+
/// Merge one nested block: serialize the inherited base block, deep-merge the
232+
/// authored raw sub-mapping over it, and deserialize the result. Skips (keeping
233+
/// the typed whole-block merge already in `slot`) when either side is absent,
234+
/// the raw value is not a mapping, or the round-trip fails.
235+
fn merge_block<T>(
236+
slot: &mut Option<T>,
237+
base_block: Option<&T>,
238+
raw_block: Option<&serde_yaml::Value>,
239+
) where
240+
T: Serialize + DeserializeOwned,
241+
{
242+
let (Some(base_block), Some(raw_block)) = (base_block, raw_block) else {
243+
return;
244+
};
245+
if !raw_block.is_mapping() {
246+
return;
247+
}
248+
let Ok(mut merged_value) = serde_yaml::to_value(base_block) else {
249+
return;
250+
};
251+
deep_merge_yaml_value(&mut merged_value, raw_block);
252+
if let Ok(block) = serde_yaml::from_value(merged_value) {
253+
*slot = Some(block);
254+
}
255+
}

0 commit comments

Comments
 (0)