iztro 0.3.1

Strongly typed Zi Wei Dou Shu chart generation aligned with iztro.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
//! Horoscope (运限) overlay models for temporal chart layers.
//!
//! A [`HoroscopeChart`] wraps an immutable natal [`Chart`] and records zero or
//! more [`TemporalLayer`] overlays. Each layer carries a non-natal [`Scope`], a
//! typed [`TemporalContext`], the star placements scoped to that period, and the
//! mutagen activations that period triggers.
//!
//! These are models only. No temporal placement algorithm, calendar derivation,
//! or year-to-ganzhi conversion is performed here: every fact is supplied
//! explicitly by the caller. Natal placements are never duplicated into a layer;
//! a layer holds only the stars a period adds or re-scopes.
//!
//! The shared mutagen-activation builder that turns a Heavenly Stem into a
//! layer's [`MutagenActivation`]s lives with the overlay placement algorithms in
//! `crate::core::placement::overlay::mutagen`.

use crate::core::{
    error::ChartError,
    model::{
        chart::{Chart, PALACE_COUNT, PalaceName, StarPlacement},
        star::StarName,
        star::mutagen::{Mutagen, Scope},
    },
};
use lunar_lite::{EarthlyBranch, StemBranch};
use serde::{Deserialize, Deserializer, Serialize};

/// Typed temporal context distinguishing each non-natal horoscope scope.
///
/// Every variant records explicit, caller-supplied facts. No calendar fact is
/// derived: the period stem-branch and any period index are stored exactly as
/// provided.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TemporalContext {
    /// Nominal-age period (小限).
    Age {
        /// Stem-branch of the nominal-age palace.
        stem_branch: StemBranch,
        /// One-based nominal age.
        nominal_age: u8,
    },
    /// Decadal period (大限).
    Decadal {
        /// Stem-branch of the decadal period.
        stem_branch: StemBranch,
        /// Explicit age at which the decade begins.
        start_age: u8,
    },
    /// Yearly period (流年).
    Yearly {
        /// Stem-branch of the flowing year.
        stem_branch: StemBranch,
        /// Explicit lunar year number, supplied rather than derived.
        lunar_year: i32,
    },
    /// Monthly period (流月).
    Monthly {
        /// Stem-branch of the flowing month.
        stem_branch: StemBranch,
        /// Explicit one-based lunar month, supplied rather than derived.
        lunar_month: u8,
    },
    /// Daily period (流日).
    Daily {
        /// Stem-branch of the flowing day.
        stem_branch: StemBranch,
        /// Explicit one-based lunar day, supplied rather than derived.
        lunar_day: u8,
    },
    /// Hourly period (流时).
    Hourly {
        /// Stem-branch of the flowing double-hour.
        stem_branch: StemBranch,
    },
}

impl TemporalContext {
    /// Returns the non-natal scope this context describes.
    pub const fn scope(&self) -> Scope {
        match self {
            Self::Age { .. } => Scope::Age,
            Self::Decadal { .. } => Scope::Decadal,
            Self::Yearly { .. } => Scope::Yearly,
            Self::Monthly { .. } => Scope::Monthly,
            Self::Daily { .. } => Scope::Daily,
            Self::Hourly { .. } => Scope::Hourly,
        }
    }

    /// Returns the stem-branch of the period.
    pub const fn stem_branch(&self) -> StemBranch {
        match self {
            Self::Age { stem_branch, .. }
            | Self::Decadal { stem_branch, .. }
            | Self::Yearly { stem_branch, .. }
            | Self::Monthly { stem_branch, .. }
            | Self::Daily { stem_branch, .. }
            | Self::Hourly { stem_branch } => *stem_branch,
        }
    }
}

/// A scoped mutagen activation produced by a temporal period.
///
/// The target palace is referenced by its [`EarthlyBranch`]: palace *names*
/// shift between scopes, so the branch is the stable spatial reference.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct MutagenActivation {
    source_scope: Scope,
    target_star: StarName,
    target_branch: EarthlyBranch,
    mutagen: Mutagen,
}

impl MutagenActivation {
    /// Creates a scoped mutagen activation fact.
    pub const fn new(
        source_scope: Scope,
        target_star: StarName,
        target_branch: EarthlyBranch,
        mutagen: Mutagen,
    ) -> Self {
        Self {
            source_scope,
            target_star,
            target_branch,
            mutagen,
        }
    }

    /// Returns the scope that produced this activation.
    pub const fn source_scope(&self) -> Scope {
        self.source_scope
    }

    /// Returns the star the mutagen lands on.
    pub const fn target_star(&self) -> StarName {
        self.target_star
    }

    /// Returns the branch of the palace the target star occupies.
    pub const fn target_branch(&self) -> EarthlyBranch {
        self.target_branch
    }

    /// Returns the mutagen applied to the target star.
    pub const fn mutagen(&self) -> Mutagen {
        self.mutagen
    }
}

/// A typed star placement positioned by branch within a temporal layer.
///
/// Natal [`StarPlacement`]s take their branch from the containing [`Palace`], but
/// a [`TemporalLayer`] is not palace-structured, so a flow placement records the
/// branch it occupies directly — the same stable spatial reference used by
/// [`MutagenActivation::target_branch`].
///
/// [`Palace`]: crate::core::model::chart::Palace
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ScopedStarPlacement {
    branch: EarthlyBranch,
    placement: StarPlacement,
}

impl ScopedStarPlacement {
    /// Creates a branch-positioned temporal star placement.
    pub const fn new(branch: EarthlyBranch, placement: StarPlacement) -> Self {
        Self { branch, placement }
    }

    /// Returns the branch this placement occupies.
    pub const fn branch(&self) -> EarthlyBranch {
        self.branch
    }

    /// Returns the underlying typed placement.
    pub const fn placement(&self) -> &StarPlacement {
        &self.placement
    }

    /// Returns the scope of the underlying placement.
    pub const fn scope(&self) -> Scope {
        self.placement.scope()
    }
}

/// One temporal palace name assigned to a branch within a horoscope period.
///
/// A temporal period (e.g. a 大限) relabels the twelve palaces around the natal
/// branch ring. The name is keyed by [`EarthlyBranch`] — the stable spatial
/// reference — because palace *names* shift between scopes. This is an additive
/// overlay fact: it never replaces the natal [`PalaceName`] at that branch.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct TemporalPalaceName {
    branch: EarthlyBranch,
    palace_name: PalaceName,
}

impl TemporalPalaceName {
    /// Creates a branch-keyed temporal palace name.
    pub const fn new(branch: EarthlyBranch, palace_name: PalaceName) -> Self {
        Self {
            branch,
            palace_name,
        }
    }

    /// Returns the branch this temporal palace name occupies.
    pub const fn branch(&self) -> EarthlyBranch {
        self.branch
    }

    /// Returns the temporal palace name assigned to the branch.
    pub const fn palace_name(&self) -> PalaceName {
        self.palace_name
    }
}

/// The twelve temporal palace names a non-natal period imposes on the branch ring.
///
/// The layout carries one [`Scope`] for all of its names; a single layout never
/// mixes scopes. The [`Scope::Natal`] scope is rejected — natal palace names
/// belong to the [`Chart`], not to a temporal overlay.
///
/// [`Deserialize`] routes through [`TemporalPalaceLayout::try_new`] so the
/// non-natal scope invariant cannot be bypassed through serialized input.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct TemporalPalaceLayout {
    scope: Scope,
    names: Vec<TemporalPalaceName>,
}

impl TemporalPalaceLayout {
    /// Creates a temporal palace-name layout after checking its invariants.
    ///
    /// Rejects the natal scope (natal palace names are spatial facts of the
    /// [`Chart`], so a temporal layout always describes a non-natal period),
    /// requires exactly [`PALACE_COUNT`] names, and requires every
    /// [`EarthlyBranch`] and every [`PalaceName`] to appear exactly once — a
    /// well-formed layout relabels each of the twelve branch positions with a
    /// distinct palace name.
    pub fn try_new(scope: Scope, names: Vec<TemporalPalaceName>) -> Result<Self, ChartError> {
        if scope == Scope::Natal {
            return Err(ChartError::NatalScopeInTemporalLayer);
        }
        if names.len() != PALACE_COUNT {
            return Err(ChartError::InvalidTemporalPalaceLayoutCount {
                expected: PALACE_COUNT,
                actual: names.len(),
            });
        }
        for (index, name) in names.iter().enumerate() {
            if names[..index]
                .iter()
                .any(|seen| seen.branch() == name.branch())
            {
                return Err(ChartError::DuplicateTemporalPalaceLayoutBranch {
                    branch: name.branch(),
                });
            }
            if names[..index]
                .iter()
                .any(|seen| seen.palace_name() == name.palace_name())
            {
                return Err(ChartError::DuplicateTemporalPalaceLayoutName {
                    palace_name: name.palace_name(),
                });
            }
        }

        Ok(Self { scope, names })
    }

    /// Returns the non-natal scope this layout describes.
    pub const fn scope(&self) -> Scope {
        self.scope
    }

    /// Returns the branch-keyed temporal palace names.
    pub fn names(&self) -> &[TemporalPalaceName] {
        &self.names
    }

    /// Returns the temporal palace name assigned to `branch`, if present.
    pub fn name_for_branch(&self, branch: EarthlyBranch) -> Option<PalaceName> {
        self.names
            .iter()
            .find(|name| name.branch() == branch)
            .map(TemporalPalaceName::palace_name)
    }
}

impl<'de> Deserialize<'de> for TemporalPalaceLayout {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        /// Mirror of [`TemporalPalaceLayout`]'s fields used only to decode raw
        /// input before the scope invariant is re-checked.
        #[derive(Deserialize)]
        struct TemporalPalaceLayoutData {
            scope: Scope,
            names: Vec<TemporalPalaceName>,
        }

        let data = TemporalPalaceLayoutData::deserialize(deserializer)?;

        TemporalPalaceLayout::try_new(data.scope, data.names).map_err(serde::de::Error::custom)
    }
}

/// A single temporal overlay on top of a natal chart.
///
/// A layer never restates natal facts: it carries only the star placements,
/// mutagen activations, and temporal palace-name layout scoped to one non-natal
/// period.
///
/// [`Deserialize`] is implemented by hand so decoding routes through
/// [`TemporalLayer::try_new`]; the scope invariants cannot be bypassed through
/// serialized input.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct TemporalLayer {
    scope: Scope,
    context: TemporalContext,
    placements: Vec<ScopedStarPlacement>,
    activations: Vec<MutagenActivation>,
    palace_layout: Option<TemporalPalaceLayout>,
}

impl TemporalLayer {
    /// Creates a temporal overlay layer with no palace-name layout.
    ///
    /// This is the common constructor for layers that carry only scoped star
    /// placements and mutagen activations. Use
    /// [`TemporalLayer::try_new_with_palace_layout`] to attach a temporal
    /// palace-name layout. The same scope invariants are checked.
    pub fn try_new(
        scope: Scope,
        context: TemporalContext,
        placements: Vec<ScopedStarPlacement>,
        activations: Vec<MutagenActivation>,
    ) -> Result<Self, ChartError> {
        Self::try_new_with_palace_layout(scope, context, placements, activations, None)
    }

    /// Creates a temporal overlay layer after checking scope invariants.
    ///
    /// Rejects the natal scope (natal facts belong to the [`Chart`]), rejects a
    /// `scope` that disagrees with `context`, rejects any placement whose scope
    /// is not the layer scope, rejects any activation whose source scope is not
    /// the layer scope, and rejects a palace layout whose scope is not the layer
    /// scope, so a layer can never duplicate or restate a natal fact.
    pub fn try_new_with_palace_layout(
        scope: Scope,
        context: TemporalContext,
        placements: Vec<ScopedStarPlacement>,
        activations: Vec<MutagenActivation>,
        palace_layout: Option<TemporalPalaceLayout>,
    ) -> Result<Self, ChartError> {
        if scope == Scope::Natal {
            return Err(ChartError::NatalScopeInTemporalLayer);
        }
        if scope != context.scope() {
            return Err(ChartError::TemporalScopeMismatch {
                layer: scope,
                context: context.scope(),
            });
        }
        if let Some(placement) = placements
            .iter()
            .find(|placement| placement.scope() != scope)
        {
            return Err(ChartError::TemporalPlacementScopeMismatch {
                layer: scope,
                placement: placement.scope(),
            });
        }
        if let Some(activation) = activations
            .iter()
            .find(|activation| activation.source_scope() != scope)
        {
            return Err(ChartError::TemporalActivationScopeMismatch {
                layer: scope,
                activation: activation.source_scope(),
            });
        }
        if let Some(layout) = &palace_layout {
            if layout.scope() != scope {
                return Err(ChartError::TemporalPalaceLayoutScopeMismatch {
                    layer: scope,
                    layout: layout.scope(),
                });
            }
        }

        Ok(Self {
            scope,
            context,
            placements,
            activations,
            palace_layout,
        })
    }

    /// Returns the non-natal scope of this layer.
    pub const fn scope(&self) -> Scope {
        self.scope
    }

    /// Returns the typed temporal context of this layer.
    pub const fn context(&self) -> &TemporalContext {
        &self.context
    }

    /// Returns the branch-positioned star placements scoped to this layer.
    pub fn placements(&self) -> &[ScopedStarPlacement] {
        &self.placements
    }

    /// Returns the mutagen activations scoped to this layer.
    pub fn activations(&self) -> &[MutagenActivation] {
        &self.activations
    }

    /// Returns the temporal palace-name layout scoped to this layer, if any.
    pub const fn palace_layout(&self) -> Option<&TemporalPalaceLayout> {
        self.palace_layout.as_ref()
    }
}

impl<'de> Deserialize<'de> for TemporalLayer {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        /// Mirror of [`TemporalLayer`]'s fields used only to decode raw input
        /// before the scope invariants are re-checked.
        #[derive(Deserialize)]
        struct TemporalLayerData {
            scope: Scope,
            context: TemporalContext,
            placements: Vec<ScopedStarPlacement>,
            activations: Vec<MutagenActivation>,
            #[serde(default)]
            palace_layout: Option<TemporalPalaceLayout>,
        }

        let data = TemporalLayerData::deserialize(deserializer)?;

        TemporalLayer::try_new_with_palace_layout(
            data.scope,
            data.context,
            data.placements,
            data.activations,
            data.palace_layout,
        )
        .map_err(serde::de::Error::custom)
    }
}

/// A natal chart with zero or more temporal overlay layers.
///
/// The wrapped natal [`Chart`] is exposed by shared reference only, keeping
/// natal facts immutable. Temporal facts are additive overlays: pushing a layer
/// never alters the natal chart.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct HoroscopeChart {
    natal: Chart,
    layers: Vec<TemporalLayer>,
}

impl HoroscopeChart {
    /// Wraps a natal chart with no temporal layers.
    pub const fn new(natal: Chart) -> Self {
        Self {
            natal,
            layers: Vec::new(),
        }
    }

    /// Wraps a natal chart with the provided temporal layers.
    pub const fn with_layers(natal: Chart, layers: Vec<TemporalLayer>) -> Self {
        Self { natal, layers }
    }

    /// Returns the immutable natal chart.
    pub const fn natal(&self) -> &Chart {
        &self.natal
    }

    /// Returns the temporal overlay layers.
    pub fn layers(&self) -> &[TemporalLayer] {
        &self.layers
    }

    /// Appends a temporal overlay layer, leaving natal facts untouched.
    pub fn push_layer(&mut self, layer: TemporalLayer) {
        self.layers.push(layer);
    }

    /// Returns the overlay layers whose scope matches `scope`.
    pub fn layers_in_scope(&self, scope: Scope) -> impl Iterator<Item = &TemporalLayer> {
        self.layers
            .iter()
            .filter(move |layer| layer.scope() == scope)
    }

    /// Consumes the overlay, returning the wrapped natal chart.
    pub fn into_natal(self) -> Chart {
        self.natal
    }
}