forked from EduCraft/curriculum-project-hub
feat(model): author 是有序列表;Info(canonical) vs RawInfo(授权 surface) 入契约
一节课可多人署名(教研组),故 canonical author 是**有序列表**而非单值。on-disk 形式接受裸字符串(单作者)或数组(多作者),但此"字符串或数组"二态**只活在加载边界**: RawInfo 经归一化折叠成 canonical Info,其后系统只见 List。 Lean 母本(ADR-0008 范围):新增 Model/Info.lean,把 Info(canonical,authors: List String)与 RawInfo/RawAuthor(授权便利 surface)+ 归一化 RawInfo.toInfo 钉死—— canonical 接收端恒为列表,raw 形式不泄漏。ADR-0008 [info] 块补 author-as-list 决策。 impl 对齐:cph-model Info.author: Option<String> → authors: Vec<String>;RawAuthor untagged enum(One|Many)+ into_vec 归一化;augmented manifest 发射 author 数组 (typst document(author:) 与模板已接受 string/array)。doc 两端互引 spec。 测试:load.rs 单作者→单元素列表;target-configs fixture 加数组作者断言多作者。 验证:lake build 绿(25 jobs);cargo test 全绿;clippy 静默;TH-141(单字符串作者) check+build PDF 无回归。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -211,12 +211,23 @@ pub struct Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `[info]` table (passed through to render targets verbatim).
|
/// `[info]` table (passed through to render targets verbatim).
|
||||||
|
///
|
||||||
|
/// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
|
||||||
|
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
|
||||||
|
/// `authors` is always a list. The authoring-surface form (string-or-array
|
||||||
|
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
|
||||||
|
/// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
|
||||||
|
/// CI gate enforces this alignment (repo constitution); it is kept greppable.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct Info {
|
pub struct Info {
|
||||||
/// Lesson title.
|
/// Lesson title.
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// Author, optional.
|
/// Authors, in declared order. A lesson can have several (a teaching group),
|
||||||
pub author: Option<String>,
|
/// so this is a list, not a single name. Empty when `[info]` declares no
|
||||||
|
/// `author`. The on-disk `author` accepts either a bare string (one author)
|
||||||
|
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
|
||||||
|
/// Mirrors Lean `Info.authors : List String`.
|
||||||
|
pub authors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One `[[parts]]` entry plus its loaded element descriptor.
|
/// One `[[parts]]` entry plus its loaded element descriptor.
|
||||||
@@ -265,10 +276,36 @@ struct RawProject {
|
|||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
|
||||||
|
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
|
||||||
|
/// fill-in convenience, normalized into the canonical [`Info`] at the load
|
||||||
|
/// boundary. Not the form the rest of the model traffics in.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RawInfo {
|
struct RawInfo {
|
||||||
title: String,
|
title: String,
|
||||||
author: Option<String>,
|
author: Option<RawAuthor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-disk `author`: either a single name (`author = "…"`) or a list
|
||||||
|
/// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
|
||||||
|
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
|
||||||
|
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum RawAuthor {
|
||||||
|
One(String),
|
||||||
|
Many(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawAuthor {
|
||||||
|
/// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
|
||||||
|
/// name becomes a one-element list; a list passes through verbatim.
|
||||||
|
fn into_vec(self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
RawAuthor::One(s) => vec![s],
|
||||||
|
RawAuthor::Many(v) => v,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -361,7 +398,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
|||||||
let info = match raw.info {
|
let info = match raw.info {
|
||||||
Some(i) => Info {
|
Some(i) => Info {
|
||||||
title: i.title,
|
title: i.title,
|
||||||
author: i.author,
|
authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(),
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
diags.push(
|
diags.push(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ name = "target-configs"
|
|||||||
|
|
||||||
[info]
|
[info]
|
||||||
title = "测试课:导出目标的结构化 build 配置"
|
title = "测试课:导出目标的结构化 build 配置"
|
||||||
|
# Array form: a lesson authored by several people.
|
||||||
|
author = ["张老师", "李老师"]
|
||||||
|
|
||||||
# Full v2 config: explicit single-file artifact + an ordered typst-compile step.
|
# Full v2 config: explicit single-file artifact + an ordered typst-compile step.
|
||||||
[targets.student]
|
[targets.student]
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
|||||||
assert_eq!(lesson.project.id, "fixture-valid");
|
assert_eq!(lesson.project.id, "fixture-valid");
|
||||||
assert_eq!(lesson.project.name, "valid-2-part");
|
assert_eq!(lesson.project.name, "valid-2-part");
|
||||||
assert_eq!(lesson.info.title, "测试课:两个部件");
|
assert_eq!(lesson.info.title, "测试课:两个部件");
|
||||||
assert_eq!(lesson.info.author.as_deref(), Some("范式教育教研组"));
|
// A single-string `author` loads as a one-element list.
|
||||||
|
assert_eq!(lesson.info.authors, vec!["范式教育教研组".to_string()]);
|
||||||
|
|
||||||
// Parts preserve declared order: segment first, lemma second.
|
// Parts preserve declared order: segment first, lemma second.
|
||||||
assert_eq!(lesson.parts.len(), 2);
|
assert_eq!(lesson.parts.len(), 2);
|
||||||
@@ -149,6 +150,12 @@ fn structured_target_configs_parse_into_typed_builds() {
|
|||||||
"well-formed target configs must have no diagnostics, got: {diags:?}"
|
"well-formed target configs must have no diagnostics, got: {diags:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// An array `author` loads as the ordered author list.
|
||||||
|
assert_eq!(
|
||||||
|
lesson.info.authors,
|
||||||
|
vec!["张老师".to_string(), "李老师".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
// Declared order is preserved.
|
// Declared order is preserved.
|
||||||
assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]);
|
assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]);
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,15 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String {
|
|||||||
"title".to_string(),
|
"title".to_string(),
|
||||||
toml::Value::String(lesson.info.title.clone()),
|
toml::Value::String(lesson.info.title.clone()),
|
||||||
);
|
);
|
||||||
if let Some(author) = &lesson.info.author {
|
if !lesson.info.authors.is_empty() {
|
||||||
info.insert("author".to_string(), toml::Value::String(author.clone()));
|
let authors = lesson
|
||||||
|
.info
|
||||||
|
.authors
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(toml::Value::String)
|
||||||
|
.collect();
|
||||||
|
info.insert("author".to_string(), toml::Value::Array(authors));
|
||||||
}
|
}
|
||||||
doc.insert("info".to_string(), toml::Value::Table(info));
|
doc.insert("info".to_string(), toml::Value::Table(info));
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ fn file_tree_artifact_is_deferred() {
|
|||||||
},
|
},
|
||||||
info: Info {
|
info: Info {
|
||||||
title: "t".into(),
|
title: "t".into(),
|
||||||
author: None,
|
authors: vec![],
|
||||||
},
|
},
|
||||||
parts: vec![],
|
parts: vec![],
|
||||||
targets: vec![TargetConfig {
|
targets: vec![TargetConfig {
|
||||||
@@ -238,7 +238,7 @@ fn shell_only_target_is_deferred() {
|
|||||||
},
|
},
|
||||||
info: Info {
|
info: Info {
|
||||||
title: "t".into(),
|
title: "t".into(),
|
||||||
author: None,
|
authors: vec![],
|
||||||
},
|
},
|
||||||
parts: vec![],
|
parts: vec![],
|
||||||
targets: vec![TargetConfig {
|
targets: vec![TargetConfig {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ name = "…" # folder/display name
|
|||||||
|
|
||||||
[info] # passed through to render targets verbatim
|
[info] # passed through to render targets verbatim
|
||||||
title = "…"
|
title = "…"
|
||||||
author = "…"
|
author = "…" # one author; or a list: author = ["…", "…"]
|
||||||
|
|
||||||
[[parts]] # ORDER OF THIS ARRAY IS THE LESSON ORDER (ADR-0005)
|
[[parts]] # ORDER OF THIS ARRAY IS THE LESSON ORDER (ADR-0005)
|
||||||
kind = "segment" # one of the known kinds (ADR-0006 / stdlib set)
|
kind = "segment" # one of the known kinds (ADR-0006 / stdlib set)
|
||||||
@@ -63,6 +63,18 @@ that imports elements in order is a *generated build artifact* derived from the
|
|||||||
manifest (see ADR-0007's note that the on-disk encoding of `Lesson` is separate
|
manifest (see ADR-0007's note that the on-disk encoding of `Lesson` is separate
|
||||||
from "it's a tree"), never the canonical order.
|
from "it's a tree"), never the canonical order.
|
||||||
|
|
||||||
|
### `[info].author` is a list; the on-disk single-string form is sugar
|
||||||
|
|
||||||
|
A lesson can be authored by several people (a teaching group), so the canonical
|
||||||
|
`author` is an **ordered list**, not a single value. For authoring convenience the
|
||||||
|
on-disk form accepts **either** a bare string (`author = "…"`, one author) **or** an
|
||||||
|
array (`author = ["…", "…"]`). That "string-or-array" union lives **only at the load
|
||||||
|
boundary**: the loader normalizes a bare string to a one-element list, and
|
||||||
|
everything downstream sees a list. Absent `author` ⇒ empty list. This raw-vs-
|
||||||
|
canonical split is pinned in the Lean master as `RawInfo` (the authoring surface)
|
||||||
|
vs `Info` (the canonical model whose `authors` is always a list) — see
|
||||||
|
`spec/Spec/Courseware/Model/Info.lean`.
|
||||||
|
|
||||||
### An element folder is self-describing via `element.toml`
|
### An element folder is self-describing via `element.toml`
|
||||||
|
|
||||||
Each element folder carries an **`element.toml`** declaring its kind and its
|
Each element folder carries an **`element.toml`** declaring its kind and its
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import Spec.Courseware.Model.Primitives
|
|||||||
import Spec.Courseware.Model.RichContent
|
import Spec.Courseware.Model.RichContent
|
||||||
import Spec.Courseware.Model.Element
|
import Spec.Courseware.Model.Element
|
||||||
import Spec.Courseware.Model.Lesson
|
import Spec.Courseware.Model.Lesson
|
||||||
|
import Spec.Courseware.Model.Info
|
||||||
|
|
||||||
/-!
|
/-!
|
||||||
# Courseware.Model —— 工程文件的内容模型
|
# Courseware.Model —— 工程文件的内容模型
|
||||||
|
|
||||||
留白基元(`Primitives`)、富内容锚点(`RichContent`)、原子单位(`Element`)、单节课
|
留白基元(`Primitives`)、富内容锚点(`RichContent`)、原子单位(`Element`)、单节课
|
||||||
(`Lesson`)。决策出处 ADR-0005 / 0006。
|
(`Lesson`)、课时元信息(`Info`:canonical author 为列表 vs `RawInfo` 授权 surface)。
|
||||||
|
决策出处 ADR-0005 / 0006 / 0008。
|
||||||
-/
|
-/
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/-!
|
||||||
|
# Info —— 课时元信息:canonical 模型 vs 授权 surface
|
||||||
|
|
||||||
|
`[info]`(标题、作者)大多是 passthrough 元数据(ADR-0008),本不入契约。但**作者的
|
||||||
|
基数**是一个真分歧点:一节课可由多人(教研组)署名,故 canonical 模型里 author 是一个
|
||||||
|
**有序列表**,不是单值或可选单值。
|
||||||
|
|
||||||
|
另有一条值得钉的模式:on-disk 的授权形式是**语法糖**——单作者可写 `author = "…"`,
|
||||||
|
多作者写 `author = ["…", "…"]`——但这个"字符串或数组"的二态**只活在加载边界**:`RawInfo`
|
||||||
|
经归一化折叠成 canonical `Info`,其后不再出现。canonical 接收端始终是 `List String`,
|
||||||
|
raw 形式不泄漏进模型其余部分。这正是 `Info`(canonical)与 `RawInfo`(授权 surface)
|
||||||
|
两个结构存在的理由。
|
||||||
|
-/
|
||||||
|
|
||||||
|
namespace Spec.Courseware
|
||||||
|
|
||||||
|
/-- 作者的**授权 surface 形式**(`PINNED` 仅授权便利, ADR-0008)。on-disk 单作者可写裸
|
||||||
|
字符串、多作者写数组——填写便利,非语义分歧。此 union **只活在加载边界**,经
|
||||||
|
`RawAuthor.normalize` 折叠后不再出现。 -/
|
||||||
|
inductive RawAuthor where
|
||||||
|
/-- 单作者裸字符串 `author = "…"`。 -/
|
||||||
|
| one (name : String)
|
||||||
|
/-- 多作者数组 `author = ["…", "…"]`。 -/
|
||||||
|
| many (names : List String)
|
||||||
|
|
||||||
|
/-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组
|
||||||
|
⇒ 原样。这条钉死"canonical 接收端始终是 `List String`"。 -/
|
||||||
|
def RawAuthor.normalize : RawAuthor → List String
|
||||||
|
| .one n => [n]
|
||||||
|
| .many ns => ns
|
||||||
|
|
||||||
|
/-- 课时元信息的 **canonical 模型**(`PINNED` author 为列表, ADR-0008)。`authors` 是
|
||||||
|
**有序列表**:多人署名第一类,空列表 = 未署名。`title` 等其余字段是 passthrough 元数据,
|
||||||
|
不在此承诺更多。这是系统其余部分唯一所见的形态——author 在此**已**是列表,不再是
|
||||||
|
"字符串或数组"。 -/
|
||||||
|
structure Info where
|
||||||
|
/-- 标题(passthrough 元数据)。 -/
|
||||||
|
title : String
|
||||||
|
/-- 作者**有序列表**(空 = 未署名)。canonical 始终是列表。 -/
|
||||||
|
authors : List String
|
||||||
|
|
||||||
|
/-- 授权 surface 的 `[info]`(`PINNED` 仅授权便利, ADR-0008)。`author` 用 `RawAuthor`
|
||||||
|
(字符串或数组),`author` 缺省即未署名。此结构刻画"为便于填写而存在的 raw 形态",
|
||||||
|
**不**是模型其余部分流通的形式——它经 `RawInfo.toInfo` 归一化为 canonical `Info`。 -/
|
||||||
|
structure RawInfo where
|
||||||
|
/-- 标题。 -/
|
||||||
|
title : String
|
||||||
|
/-- 作者 raw 形式(可选;缺省即未署名)。 -/
|
||||||
|
author : Option RawAuthor
|
||||||
|
|
||||||
|
/-- raw `[info]` 归一化为 canonical `Info`(`PINNED` 加载边界归一化, ADR-0008)。缺省
|
||||||
|
author ⇒ 空列表,否则按 `RawAuthor.normalize`。raw 的"字符串或数组"二态在此被消解,
|
||||||
|
**不**泄漏进 `Info`——canonical 接收端恒为 `List String`。 -/
|
||||||
|
def RawInfo.toInfo (r : RawInfo) : Info :=
|
||||||
|
{ title := r.title
|
||||||
|
authors := (r.author.map RawAuthor.normalize).getD [] }
|
||||||
|
|
||||||
|
end Spec.Courseware
|
||||||
Reference in New Issue
Block a user