← domain #3
Source : melodic_derivations.py
data/library/books/brihaddesi_sharma_1992/formal_grammar/melodic_derivations.py · 577 lines · 22717 bytes
"""Domaine 3 — mélodique modal (mūrchanā, grāma, tāna, svara)
Synthèse 6c.3 du Brihaddesi (Sharma 1992, vols I & II) depuis :
- 114 règles génératives 6b
- 241 affirmations sourcées
- structures 4e (tables mūrchanā-maṇḍala p.037, mūrchanā nommées p.051,
tables tāna p.040)
- partition Leiden domain_id=3 (104 concepts)
Anti-fabrication : chaque type, opération et contrainte cite son evidence
(rule_id 6b ou affirmation_id). Aucune valeur non sourcée n'est introduite.
Python 3.10+. Importable directement, sans dépendance externe.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Iterable, Literal
# =============================================================================
# CONSTANTES — énumérations fermées du Brihaddesi
# =============================================================================
class Svara(str, Enum):
"""The seven svaras of the gāmut.
evidence: R_c57_grama, R_c39_murchana_devanagari, structures 4e p.037
"""
SA = "sa" # ṣaḍja
RI = "ri" # ṛṣabha
GA = "ga" # gāndhāra
MA = "ma" # madhyama
PA = "pa" # pañcama
DHA = "dha" # dhaivata
NI = "ni" # niṣāda
SVARA_ORDER: tuple[Svara, ...] = (
Svara.SA, Svara.RI, Svara.GA, Svara.MA, Svara.PA, Svara.DHA, Svara.NI,
)
class Grāma(str, Enum):
"""The two operative grāmas in Brihaddesi.
Brihaddesi treats only ṣaḍja-grāma and madhyama-grāma as live frames.
A third (gāndhāra-grāma) is alluded to in tradition but no rule states
its svara content here — see UNRESOLVED.
evidence: R_c57_grama, R_319_gramas (differentiation by pañcama position)
"""
SADJA = "sadjagrama"
MADHYAMA = "madhyamagrama"
class ScaleType(str, Enum):
"""Density of a svara collection.
evidence: R_147_auduva_definition (auduva=5), R_1266_sadjamadhyama_struct
(purna=7, sadava=6, auduva=5)
"""
SAMPURNA = "sampurna" # full 7-svara set
SADAVA = "sadava" # hexatonic (one omission)
AUDUVA = "auduva" # pentatonic (two omissions)
class Sthāna(str, Enum):
"""The three vocal registers — objective of tānas.
evidence: R_2049_01, R_520_sthana, R_271_kaku_def
"""
MANDRA = "mandra" # low
MADHYA = "madhya" # middle
TARA = "tara" # high
# -----------------------------------------------------------------------------
# Mūrchanā-maṇḍala — tables 4e p.037 (Sharma vol. I)
# -----------------------------------------------------------------------------
# Each row is one mūrchanā = a 7-svara rotation of the grāma frame.
# These tables are extracted verbatim from the OCR'd Brihaddesi diagrams,
# not derived theoretically.
# evidence: structures 4e p.037 (concept=mūrchanā-maṇḍala), confidence=0.95
# corroborated by R_c57_grama (murchanas_start_order)
MURCHANA_MANDALA_SADJA: tuple[tuple[Svara, ...], ...] = (
# Header row of source diagram: ni-dha-pa-ma-ga-ri-sa
(Svara.NI, Svara.DHA, Svara.PA, Svara.MA, Svara.GA, Svara.RI, Svara.SA),
(Svara.DHA, Svara.PA, Svara.MA, Svara.GA, Svara.RI, Svara.SA, Svara.NI),
(Svara.PA, Svara.MA, Svara.GA, Svara.RI, Svara.SA, Svara.NI, Svara.DHA),
(Svara.MA, Svara.GA, Svara.RI, Svara.SA, Svara.NI, Svara.DHA, Svara.PA),
(Svara.GA, Svara.RI, Svara.SA, Svara.NI, Svara.DHA, Svara.PA, Svara.MA),
(Svara.RI, Svara.SA, Svara.NI, Svara.DHA, Svara.PA, Svara.MA, Svara.GA),
(Svara.SA, Svara.NI, Svara.DHA, Svara.PA, Svara.MA, Svara.GA, Svara.RI),
)
MURCHANA_MANDALA_MADHYAMA: tuple[tuple[Svara, ...], ...] = (
# Header row of source diagram: ga-ri-sa-ni-dha-pa-ma
(Svara.GA, Svara.RI, Svara.SA, Svara.NI, Svara.DHA, Svara.PA, Svara.MA),
(Svara.RI, Svara.SA, Svara.NI, Svara.DHA, Svara.PA, Svara.MA, Svara.GA),
(Svara.SA, Svara.NI, Svara.DHA, Svara.PA, Svara.MA, Svara.GA, Svara.RI),
(Svara.NI, Svara.DHA, Svara.PA, Svara.MA, Svara.GA, Svara.RI, Svara.SA),
(Svara.DHA, Svara.PA, Svara.MA, Svara.GA, Svara.RI, Svara.SA, Svara.NI),
(Svara.PA, Svara.MA, Svara.GA, Svara.RI, Svara.SA, Svara.NI, Svara.DHA),
(Svara.MA, Svara.GA, Svara.RI, Svara.SA, Svara.NI, Svara.DHA, Svara.PA),
)
MURCHANA_MANDALA: dict[Grāma, tuple[tuple[Svara, ...], ...]] = {
Grāma.SADJA: MURCHANA_MANDALA_SADJA,
Grāma.MADHYAMA: MURCHANA_MANDALA_MADHYAMA,
}
# Named mūrchanās — Bharata enumeration cited by Mātaṅga (Sharma vol. I p.051)
# 14 names, 7 per grāma.
# evidence: structures 4e p.051, R_550_uttaramandra_murchana,
# R_1818_asvakranta_murchana, R_5p3_1819, R_c1821_sauviri_murchana,
# R_1822_001, kalopanata_madhyamagrama_twelve_svara, R_1824_01,
# R_1825_margi_murchana_madhyamagrama, R_1826_pauravi_murchana_sequence,
# R_1827_hrsyaka_murchana, R_1748_rajani_nisada
NAMED_MURCHANAS_SADJA: tuple[str, ...] = (
"uttaramandrā", "rajanī", "uttarāyatā", "śuddhaṣaḍjā",
"matsarīkṛtā", "aśvakrāntā", "abhirudgatā",
)
NAMED_MURCHANAS_MADHYAMA: tuple[str, ...] = (
"sauvīrī", "hariṇāśvā", "kalopanatā", "śuddhamadhyā",
"mārgavī", "pauravī", "hṛṣyakā",
)
# Starting-svara mapping for named mūrchanās — extracted from rule bodies
# that give concrete svara sequences (R_1818..R_1827) and R_550, R_1748.
# evidence per entry below.
NAMED_MURCHANA_START: dict[str, tuple[Grāma, Svara]] = {
"uttaramandrā": (Grāma.SADJA, Svara.SA), # R_550_uttaramandra_murchana
"rajanī": (Grāma.SADJA, Svara.NI), # R_1748_rajani_nisada
"aśvakrāntā": (Grāma.SADJA, Svara.MA), # R_1818_asvakranta_murchana
"abhirudgatā": (Grāma.SADJA, Svara.PA), # R_5p3_1819
"sauvīrī": (Grāma.MADHYAMA, Svara.NI), # R_c1821_sauviri_murchana
"hariṇāśvā": (Grāma.MADHYAMA, Svara.SA), # R_1822_001
"kalopanatā": (Grāma.MADHYAMA, Svara.RI), # kalopanata_..._twelve_svara
"śuddhamadhyā": (Grāma.MADHYAMA, Svara.GA), # R_1824_01
"mārgavī": (Grāma.MADHYAMA, Svara.MA), # R_1825_margi_murchana
"pauravī": (Grāma.MADHYAMA, Svara.PA), # R_1826_pauravi_murchana
"hṛṣyakā": (Grāma.MADHYAMA, Svara.DHA), # R_1827_hrsyaka_murchana
# uttarāyatā, śuddhaṣaḍjā, matsarīkṛtā : named in p.051 list, no individual
# starting-svara rule found in 6b — see UNRESOLVED.
}
# -----------------------------------------------------------------------------
# Inomissibility — which svaras cannot be dropped to form ṣāḍava / auduva
# -----------------------------------------------------------------------------
# evidence: madhyamagrama_rsabha_pancama_inomissible (aff#612, aff#1935),
# sadjagrama_inomissible_and_vivadi (aff#1924, aff#2294),
# sadjagrama_sadava_murchana_formation (aff#290, aff#2408)
INOMISSIBLE_SVARAS: dict[Grāma, frozenset[Svara]] = {
# ṣaḍja-grāma: dhaivata is inomissible; gāndhāra-omission is "not
# prescribed" for ṣāḍava (aff#290). madhyama is always inomissible.
Grāma.SADJA: frozenset({Svara.DHA, Svara.MA}),
# madhyama-grāma: ṛṣabha and pañcama are inomissible (aff#612);
# pañcama specifically "inomissible according to Datti" (aff#1935).
Grāma.MADHYAMA: frozenset({Svara.RI, Svara.PA, Svara.MA}),
}
# Permissible svaras for ṣāḍava omission per grāma (closed enumeration).
# evidence: sadjagrama_sadava_murchana_formation (rule body explicitly lists
# the 4 allowed omissions); for madhyama-grāma we derive by complement of
# inomissible set (no positive enumeration rule found).
SADAVA_OMISSION_CANDIDATES: dict[Grāma, frozenset[Svara]] = {
Grāma.SADJA: frozenset({Svara.SA, Svara.RI, Svara.PA, Svara.NI}),
# Madhyama: SA, GA, DHA, NI allowed (by complement of inomissible {RI,PA,MA})
Grāma.MADHYAMA: frozenset({Svara.SA, Svara.GA, Svara.DHA, Svara.NI}),
}
# -----------------------------------------------------------------------------
# Auduva (pentatonic) sub-categories — closed pairs of co-omitted svaras
# -----------------------------------------------------------------------------
# evidence: R_147_auduva_definition (subtypes={devoid_niga, devoid_pani}),
# R_1769_nigahina_auduva, R_5p3_1770, R_1786_01
AUDUVA_OMISSION_PAIRS: tuple[frozenset[Svara], ...] = (
frozenset({Svara.NI, Svara.GA}), # nigahīnā
frozenset({Svara.PA, Svara.NI}), # auduva-parihīna
)
# -----------------------------------------------------------------------------
# Tāna counts — enumerative facts from the Brihaddesi
# -----------------------------------------------------------------------------
# evidence: BD_6_1_R007 (21 ṣāḍava tānas in madhyama),
# R_4p0_025 (14 auduvita in madhyama, 35 total auduvita,
# 84 = ṣāḍava+auduvita across both grāmas),
# audavita_tanas_count_sadjagrama (21 audavita in ṣaḍja)
TANA_COUNTS: dict[tuple[Grāma, ScaleType], int] = {
(Grāma.MADHYAMA, ScaleType.SADAVA): 21, # BD_6_1_R007
(Grāma.MADHYAMA, ScaleType.AUDUVA): 14, # R_4p0_025
(Grāma.SADJA, ScaleType.AUDUVA): 21, # audavita_tanas_count_sadjagrama
# SADJA × SADAVA: no direct count rule isolated to ṣaḍja alone (see UNRESOLVED)
}
TANAS_AUDUVA_BOTH_GRAMAS: int = 35 # R_4p0_025
TANAS_SADAVA_PLUS_AUDUVA_BOTH: int = 84 # R_4p0_025
# -----------------------------------------------------------------------------
# Named auduvita tānas devoid of ni and ga, in madhyama-grāma
# -----------------------------------------------------------------------------
# evidence: R_1786_01 (full enumeration), R_1787_moksada_seventh_auduvita
AUDUVITA_TANAS_NIGAHINA_MADHYAMA: tuple[str, ...] = (
"bhairava", "kāmada", "avabhṛtha", "aṣṭakapālaka",
"sviṣṭakṛt", "vaṣaṭkāra", "mokṣada",
)
# =============================================================================
# TYPES — dataclasses
# =============================================================================
@dataclass(frozen=True)
class Mūrchanā:
"""A mūrchanā is a 7-svara cyclic rotation of a grāma scale, starting on
one specific svara — the "vehicle of rāga growth" (etymology mūrch +
samucchrāya).
A second sense, "12-svara mūrchanā", appears in performance context for
attaining the three sthānas (see Mūrchanā12).
evidence: R_c39_murchana_devanagari (definition + start_svara),
murchana_definition_heptad_ascent_descent,
murchana_twofold_seven_or_twelve_svaras
"""
grāma: Grāma
starting_svara: Svara
sequence: tuple[Svara, ...] # length 7
name: str | None = None # canonical name if known (uttaramandrā, etc.)
def __post_init__(self) -> None:
if len(self.sequence) != 7:
raise ValueError(
f"Mūrchanā must have 7 svaras, got {len(self.sequence)}"
)
if self.sequence[0] != self.starting_svara:
raise ValueError(
f"sequence[0]={self.sequence[0]} != starting_svara={self.starting_svara}"
)
if set(self.sequence) != set(SVARA_ORDER):
raise ValueError("Mūrchanā sequence must permute all 7 svaras")
@dataclass(frozen=True)
class Mūrchanā12:
"""A twelve-svara mūrchanā used in rāga performance to attain the three
sthānas. Subsumed within ṣaḍja-grāma mūrchanās during performance.
evidence: murchana_twofold_seven_or_twelve_svaras (aff#2369, aff#2507),
murchana_twelve_svara_for_three_sthanas,
R_549_gramamurchana
"""
grāma: Grāma
sequence: tuple[Svara, ...] # length 12
name: str | None = None
def __post_init__(self) -> None:
if len(self.sequence) != 12:
raise ValueError(
f"Mūrchanā12 must have 12 svaras, got {len(self.sequence)}"
)
@dataclass(frozen=True)
class Tāna:
"""A tāna is the result of a svara-omission operation on a mūrchanā /
scale, producing a ṣāḍava (6-note) or auduva (5-note) collection.
"Omission of svaras is operative only in mūrchanās and not in grāma"
(aff#1925).
evidence: tana_definition (aff#3097), R_275_murchana_omission (aff#1925),
tana_definition_cyclic_permutations
"""
parent_grāma: Grāma
omitted: frozenset[Svara]
svaras: frozenset[Svara]
scale_type: ScaleType
name: str | None = None
def __post_init__(self) -> None:
n = len(self.svaras)
if n == 5 and self.scale_type != ScaleType.AUDUVA:
raise ValueError("5-svara tāna must be ScaleType.AUDUVA")
if n == 6 and self.scale_type != ScaleType.SADAVA:
raise ValueError("6-svara tāna must be ScaleType.SADAVA")
if n not in (5, 6):
raise ValueError(f"Tāna must have 5 or 6 svaras, got {n}")
if self.svaras & self.omitted:
raise ValueError("svaras and omitted must be disjoint")
if self.svaras | self.omitted != set(SVARA_ORDER):
raise ValueError("svaras + omitted must cover all 7 svaras")
@dataclass(frozen=True)
class GrāmaSpec:
"""Static specification of a grāma: its inomissible svaras, ordered
cyclic frame, and structural properties.
evidence: R_c57_grama, R_319_gramas, sadjagrama_inomissible_and_vivadi,
madhyamagrama_rsabha_pancama_inomissible
"""
name: Grāma
inomissible: frozenset[Svara]
n_murchanas: int # 7 — same for both grāmas
n_jatis: int # 7 for ṣaḍja-grāma, 11 for madhyama-grāma
GRAMA_SPECS: dict[Grāma, GrāmaSpec] = {
Grāma.SADJA: GrāmaSpec(
name=Grāma.SADJA,
inomissible=INOMISSIBLE_SVARAS[Grāma.SADJA],
n_murchanas=7,
n_jatis=7, # sadjagrama_seven_jatis (aff#...)
),
Grāma.MADHYAMA: GrāmaSpec(
name=Grāma.MADHYAMA,
inomissible=INOMISSIBLE_SVARAS[Grāma.MADHYAMA],
n_murchanas=7,
n_jatis=11, # madhyamagrama_jati_basis
),
}
# =============================================================================
# OPÉRATIONS — dérivations exécutables
# =============================================================================
def murchana_at(grāma: Grāma, k: int) -> Mūrchanā:
"""Return the k-th mūrchanā of a grāma from the mūrchanā-maṇḍala table.
The mūrchanā-maṇḍala (Sharma vol. I p.037) is the canonical source: each
row is one of the 7 rotations. We do NOT compute the rotation from
SVARA_ORDER — we read it from the source diagram.
evidence: structures 4e p.037 (mūrchanā-maṇḍala),
R_597_murchana_shift (sadja-position theorem),
R_c39_murchana_devanagari (etymology + structure)
"""
table = MURCHANA_MANDALA[grāma]
if not (0 <= k < len(table)):
raise IndexError(f"k must be in [0, {len(table)}); got {k}")
seq = table[k]
return Mūrchanā(
grāma=grāma,
starting_svara=seq[0],
sequence=seq,
)
def all_murchanas(grāma: Grāma) -> list[Mūrchanā]:
"""Enumerate the 7 mūrchanās of a grāma.
evidence: R_c57_grama, structures 4e p.037
"""
return [murchana_at(grāma, k) for k in range(len(MURCHANA_MANDALA[grāma]))]
def named_murchana(name: str) -> Mūrchanā | None:
"""Build a named mūrchanā if its starting svara is sourced.
evidence: NAMED_MURCHANA_START dict (each entry has its own rule_id)
Returns None if name is in the canonical 14-list but its start svara is
not pinned by a rule (see UNRESOLVED).
"""
if name not in NAMED_MURCHANA_START:
return None
grāma, start = NAMED_MURCHANA_START[name]
table = MURCHANA_MANDALA[grāma]
for seq in table:
if seq[0] == start:
return Mūrchanā(
grāma=grāma, starting_svara=start, sequence=seq, name=name,
)
return None
def sadava_from_sampurna(grāma: Grāma, omit: Svara) -> Tāna:
"""Derive a ṣāḍava (hexatonic) tāna by omitting one svara.
Raises if `omit` is inomissible in `grāma`.
evidence: sadjagrama_sadava_murchana_formation (aff#290, aff#2408),
R_275_murchana_omission (aff#1925),
madhyamagrama_rsabha_pancama_inomissible (aff#612, aff#1935)
"""
if not is_valid_omission(grāma, frozenset({omit})):
raise ValueError(
f"Omission of {omit.value} not permitted in {grāma.value}"
)
svaras = frozenset(SVARA_ORDER) - {omit}
return Tāna(
parent_grāma=grāma,
omitted=frozenset({omit}),
svaras=svaras,
scale_type=ScaleType.SADAVA,
)
def auduva_from_sampurna(
grāma: Grāma, omit_pair: frozenset[Svara]
) -> Tāna:
"""Derive an auduva (pentatonic) tāna by omitting a pair of svaras.
Brihaddesi defines exactly 2 closed auduva sub-categories: devoid of
ni+ga, and devoid of pa+ni. Other pairs are out of scope here.
evidence: R_147_auduva_definition (aff#2309, aff#2426),
R_1769_nigahina_auduva, R_5p3_1770, R_1786_01
"""
if omit_pair not in AUDUVA_OMISSION_PAIRS:
raise ValueError(
f"Auduva omission pair {set(omit_pair)} not canonical "
f"({{ni,ga}} or {{pa,ni}} only — R_147)"
)
if not is_valid_omission(grāma, omit_pair):
raise ValueError(
f"At least one of {set(omit_pair)} is inomissible in {grāma.value}"
)
svaras = frozenset(SVARA_ORDER) - omit_pair
return Tāna(
parent_grāma=grāma,
omitted=omit_pair,
svaras=svaras,
scale_type=ScaleType.AUDUVA,
)
def substitute_madhyama_for_sadja(
phrase: list[Svara], position: int, placed_by_murchana: bool
) -> list[Svara]:
"""Where madhyama is placed in a phrase by virtue of the mūrchanā
principle, ṣaḍja may replace it without destroying the jāti or rāga.
evidence: murchana_substitution_madhyama_for_sadja (aff#2270, aff#2277)
"""
if not placed_by_murchana:
return list(phrase)
if phrase[position] != Svara.MA:
return list(phrase)
out = list(phrase)
out[position] = Svara.SA
return out
# =============================================================================
# CONTRAINTES — validations
# =============================================================================
def is_valid_omission(grāma: Grāma, omit: frozenset[Svara]) -> bool:
"""True iff none of the svaras in `omit` is inomissible in `grāma`.
evidence: INOMISSIBLE_SVARAS (see rule citations on that dict)
"""
return not (omit & INOMISSIBLE_SVARAS[grāma])
def is_valid_mūrchanā(seq: Iterable[Svara]) -> bool:
"""A valid mūrchanā is a 7-svara permutation of the full svara set.
evidence: R_c39_murchana_devanagari (length=7),
murchana_definition_heptad_ascent_descent
"""
seq = tuple(seq)
return len(seq) == 7 and set(seq) == set(SVARA_ORDER)
def is_valid_tana_count(grāma: Grāma, scale: ScaleType, count: int) -> bool:
"""Cross-check a tāna count against the Brihaddesi enumerative facts.
evidence: TANA_COUNTS dict (BD_6_1_R007, R_4p0_025,
audavita_tanas_count_sadjagrama)
Returns True if no count is known (under-constrained) — see UNRESOLVED.
"""
expected = TANA_COUNTS.get((grāma, scale))
if expected is None:
return True
return count == expected
def grama_differentiated_by_pancama(
g1: Grāma, g2: Grāma
) -> bool:
"""The two grāmas are differentiated on the basis of pañcama alone
(specifically: pañcama's śruti position differs by one).
evidence: R_319_gramas, madhyama_grama_mandalas_and_pancama_sruti
"""
return g1 != g2 and {g1, g2} == {Grāma.SADJA, Grāma.MADHYAMA}
# =============================================================================
# UNRESOLVED — concepts mentioned but not formally pinned by any 6b rule
# =============================================================================
# Listed here for traceability; consumed by the JSON manifest.
UNRESOLVED: tuple[dict[str, str], ...] = (
{
"concept": "gāndhāra-grāma",
"reason": "alluded to in Indic tradition but no Brihaddesi 6b rule "
"pins its svaras or operative role — only SADJA + MADHYAMA "
"are modelled (R_c57_grama).",
},
{
"concept": "uttarāyatā / śuddhaṣaḍjā / matsarīkṛtā",
"reason": "named in canonical 14-list (structures 4e p.051) but no "
"individual rule isolates their starting svara.",
},
{
"concept": "ṣāḍava count in ṣaḍja-grāma",
"reason": "BD_6_1_R007 gives 21 for madhyama-grāma; no isolated "
"count for ṣaḍja-grāma ṣāḍava in the rule set.",
},
{
"concept": "jāti → mūrchanā mapping",
"reason": "R_275_murchana_omission explicitly states: 'No rule is "
"prescribed for assigning a particular mūrchanā to a "
"given jāti' (aff#1933) — authority-based, not derivable.",
},
{
"concept": "shuddhakaisikamadhyama grāma assignment",
"reason": "Disputed (R_486_shuddhakaisikamadhyama_grama_disputed); "
"not modelled.",
},
)
# =============================================================================
# Self-test (executed at import-time only if __main__)
# =============================================================================
if __name__ == "__main__":
# Sanity: every mūrchanā in the maṇḍala is a valid permutation
for g in Grāma:
for k in range(len(MURCHANA_MANDALA[g])):
m = murchana_at(g, k)
assert is_valid_mūrchanā(m.sequence), (g, k, m.sequence)
assert len(all_murchanas(g)) == 7
# Sanity: named lookup
m = named_murchana("aśvakrāntā")
assert m is not None and m.starting_svara == Svara.MA
assert m.grāma == Grāma.SADJA
# Sanity: omission constraints
assert is_valid_omission(Grāma.MADHYAMA, frozenset({Svara.SA}))
assert not is_valid_omission(Grāma.MADHYAMA, frozenset({Svara.PA}))
assert not is_valid_omission(Grāma.SADJA, frozenset({Svara.DHA}))
# Sanity: sadava derivation
t = sadava_from_sampurna(Grāma.SADJA, Svara.SA)
assert t.scale_type == ScaleType.SADAVA
assert Svara.SA not in t.svaras
# Sanity: auduva derivation
t2 = auduva_from_sampurna(Grāma.SADJA, frozenset({Svara.NI, Svara.GA}))
assert t2.scale_type == ScaleType.AUDUVA
assert {Svara.NI, Svara.GA}.isdisjoint(t2.svaras)
# Sanity: tāna counts
assert is_valid_tana_count(Grāma.MADHYAMA, ScaleType.SADAVA, 21)
assert not is_valid_tana_count(Grāma.MADHYAMA, ScaleType.SADAVA, 22)
# Sanity: substitution
phrase = [Svara.SA, Svara.RI, Svara.MA, Svara.PA]
out = substitute_madhyama_for_sadja(phrase, 2, placed_by_murchana=True)
assert out[2] == Svara.SA
print("melodic_derivations.py — all self-tests pass")