Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions mydocs/report/task_dsel_selector_language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# DSEL — 문서 선택자 언어 (에이전트 조작 커널 1층)

- 이슈: #4875 (로드맵 — 에이전트 문서 조작 커널)
- 브랜치: `agent_op_kernel` (upstream/devel `627c8c49a` 기준)
- 스코프: **1층 DSEL 만** — 문서의 "어디"를 값으로 만드는 층. 연산 대수(2층)
이하와 CLI·MCP 표면은 후속 이슈로 쌓는다.

## 무엇

`rhwp::agent::dsel` — CSS 선택자를 rhwp IR 에 맞춰 좁힌 문서 지목 언어.
라이브러리 표면(`pub mod agent`)에 선다.

```rust
use rhwp::agent::dsel;

let sel = dsel::parse("section:nth(2) > table:last cell[row=-1]")?;
let hits = dsel::select(&sel, &document)?;
for node in hits {
println!("{} = {:?}", node.id, node.node.text());
}
// /section[2]/para[7]/control[0]/cell[11] = Some("합계")
```

### 문법

```text
selector := path ("," path)*
path := step (combinator step)*
comb := " " (자손) | ">" (직계) | "+" (다음 형제) | "~" (이후 형제)
step := axis predicate*
axis := section|para|run|control|table|cell|picture|equation
| field|footnote|endnote|header|footer|bookmark|hyperlink|shape|*
pred := "[" name (op value)? "]" | ":" pseudo ("(" arg ")")?
op := "=" | "!=" | ">" | "<" | ">=" | "<=" | "^=" | "$=" | "*=" | "~="
pseudo := first|last|empty|nth(n)|range(a..b)|contains(s)|matches(s)|not(sel)|has(sel)
```

축 16종은 전부 IR 의 실제 노드 종류다. 지어낸 층은 없다.

```text
Document
└ section Document::sections
└ para Section::paragraphs
├ run Paragraph::char_shapes 가 나누는 구간
└ control Paragraph::controls
├ table Control::Table
│ └ cell Table::cells
│ └ para Cell::paragraphs ← 여기서 재귀한다
├ picture/equation/field/…
└ footnote/header/footer
└ para 각자의 문단 목록
```

### 노드 주소

선택 결과는 참조가 아니라 **구조 경로**다.

```text
/section[0]/para[3]/control[0]/cell[5]/para[0]
```

참조로 돌려주면 문서를 고치는 순간 죽는다. 커널의 요점이 "고르고 → 고치고 →
확인"이므로 선택 결과는 편집을 사이에 두고 살아남는 표현이어야 한다. 경로의
**사전식 순서가 곧 문서 순서**라는 성질도 함께 얻는다(조상은 접두사, 앞 형제는
작은 순번) — 결과 정렬에 별도 비교기가 필요 없다.

## 판단

### 1. 사전은 하나뿐이다

축·속성·의사 선택자 목록은 `ast.rs` 의 상수 사전 한 벌이고, 파서·평가기·진단이
전부 그것만 읽는다. 목록이 세 곳에 있으면 반드시 어긋난다 —
`capabilities`·`ir_schema`·`ontology` 를 전부 유도로 만든 것과 같은 이유다.

축 계층(`table` 은 `control` 의 특수화)도 데이터로 적었다(`AxisKind::specializes`).
`ontology` 가 `rdfs:subClassOf` 를 유도할 때 손으로 계층을 다시 적지 않아도 되고,
"억지 계층 금지" 규약도 지켜진다 — 여기 적힌 관계는 평가기가 실제로 그렇게
구현한 것뿐이다. `cell` 이 `table` 의 특수화가 **아닌** 것(포함 관계이지
특수화가 아님)을 테스트가 고정한다.

### 2. 파싱 중에 의미까지 검사한다

축 이름·속성 이름·연산자 적합성을 파싱 단계에서 확인한다. 문법만 보고 통과시킨 뒤
평가에서 거절하면 오류 위치가 "이 선택자 어딘가"로 뭉개진다.

```
$ para[rows>1]
축 `para` 에 없는 속성 `rows` — 기대: controls | empty | index | len | shapeId | styleId | text
para[rows>1]
^
```

진단은 세 가지를 값으로 싣는다 — `offset`(문자 기준, 바이트 아님)·`expected`(닫힌
후보 목록)·`hint`(관측된 오용의 교정). 선택자를 쓰는 쪽은 대부분 모델이고,
`"parse error"` 한 줄은 모델에게 "다시 추측하라"는 뜻이다. 추측 왕복 한 번이
실패 한 번이다.

### 3. 위치 의사 선택자는 결과 집합 기준, `[index]` 는 형제 기준

`:first`·`:last`·`:nth`·`:range` 는 **그 스텝의 결과 집합**에서 센다.
`table:last` 는 "문서에서 마지막으로 걸린 표"이지 "자기 문단의 마지막 표"가 아니다.

CSS 의 `:last-child` 는 후자지만 에이전트가 쓰는 표현은 거의 언제나 전자다
("마지막 표의 합계 행"). 형제 기준이 필요하면 `[index]` 속성이 그대로 남아 있다.
두 기준을 **다른 문법에** 두어 어느 쪽인지 항상 눈에 보이게 했다.

값 술어를 위치 술어보다 **먼저** 적용한다. 순서가 뒤바뀌면 `table:last[rows>2]` 가
"마지막 표를 고른 뒤 행 수를 본다"가 되어 마지막 표의 행이 둘 이하면 결과가 빈다.
사람이 뜻한 것은 거의 언제나 "행이 셋 이상인 표들 중 마지막"이다.

### 4. 없는 값은 어떤 조건도 만족하지 않는다

속성이 없으면 `!=` 로도 참이 되지 않는다. `cell[name!="합계"]` 가 이름 없는 셀을
전부 고르면, 이름을 붙이지 않은 셀이 갑자기 편집 대상이 되어 편집이 새어 나간다.
없는 것은 비교의 대상이 아니라는 규칙이 편집 안전에서 더 옳다.

같은 이유로 빈 필드 이름은 값으로 내지 않는다 — 빈 문자열을 내면 `[name]` 존재
검사가 참이 되어 "이름 있는 필드"를 잘못 센다.

### 5. 결합자는 부모 색인 비교로 환원된다

평가는 문서를 문서 순서의 평평한 목록으로 한 번 펼친 뒤 그 위에서 접는다.
트리를 직접 재귀하면 결합자 네 종마다 다른 순회 방향이 필요해 코드가 네 갈래로
갈라지는데, 펼쳐 두면 전부 부모 색인 비교가 된다.

| 결합자 | 판정 |
| --- | --- |
| `>` | `flat[n].parent == Some(c)` |
| ` ` | `c` 가 `n` 의 조상 사슬에 있다 |
| `+` | 같은 부모·같은 종류·`n.index == c.index + 1` |
| `~` | 같은 부모·같은 종류·`n.index > c.index` |

형제 판정이 **종류까지** 보는 것이 중요하다. 한 문단은 컨트롤과 구간을 함께
갖는데, `+` 가 종류를 보지 않으면 컨트롤 다음에 오는 구간이 "다음 형제"가 되어
글자 모양이 하나 바뀔 때마다 같은 선택자가 다른 것을 고른다.

대가는 노드 수만큼의 메모리인데 상한으로 유계이므로, 손상·거대 입력에서도
예측 가능한 실패(에러)로 끝난다 — 예측 불가능한 실패(OOM)가 아니라.

### 6. 정규식을 쓰지 않는다

두 가지다.

1. **의존성** — rhwp 는 정규식 크레이트를 쓰지 않는다. 선택자 하나 때문에
늘리면 wasm 크기와 감사 표면이 함께 는다.
2. **정지성** — 역추적 정규식은 입력에 따라 지수 시간으로 터진다. 선택자는
**문서에서 온 값**과 맞대어지고 문서는 신뢰 경계 바깥이다(`provenance::MAP` 이
같은 말을 한다). 신뢰할 수 없는 입력에 지수 시간 판정기를 붙이는 것은 DoS 를
스스로 심는 것이다.

그래서 문법을 글롭으로 좁히고 **역추적 지점이 `*` 하나뿐**인 고전 알고리즘을 쓴다.
연속 `*` 는 컴파일 때 하나로 접는다 — 접지 않으면 `***` 가 역추적 지점을 셋
만들어 최악 시간이 패턴 길이에 곱해진다.

### 7. 신뢰 경계

선택자는 문서 **밖에서** 오고(사람·계획서·모델), 선택자가 맞대는 값은 문서
**안에서** 온다. 문서에서 읽은 문자열이 선택자 문법으로 재해석되는 경로는
존재하지 않는다 — 문서에 `para:has(...)` 라고 적혀 있어도 그건 그냥 글자다.
이 성질을 테스트가 고정한다(`document_derived_text_is_never_reinterpreted_as_syntax`).

## 상한 (전부 테스트로 고정)

| 층 | 상한 | 값 |
| --- | --- | --- |
| 파싱 | 원문 길이 | 4096 자 |
| 파싱 | `:has`/`:not` 중첩 | 8 |
| 파싱 | 합집합 가지 | 64 |
| 파싱 | 경로 스텝 | 32 |
| 파싱 | 스텝당 술어 | 16 |
| 평가 | 펼칠 노드 | 200,000 |
| 평가 | 문서 중첩 깊이 | 64 |
| 평가 | 결과 | 50,000 |

중첩 깊이는 파서와 평가기 **양쪽**에서 막는다. 파서를 거치지 않은 AST(계획서에서
역직렬화한 선택자 등)로도 평가가 불릴 수 있기 때문이다.

`count()` 는 `max_results` 에 막히지 않는다. 몇 개인지 알아야 상한을 넘겼는지
판단할 수 있는데 세는 것조차 막히면 진단이 "너무 많다"에서 멈춰 몇 개인지 영영
알 수 없다.

## 손상 입력 방어

문단을 구간으로 자르는 경로(`runs_of`)가 실물 손상 문서를 만난다. 자를 수 없으면
**잘못 자르는 대신 구간을 만들지 않는다** — 선택자가 아무것도 못 고르는 것은
복구 가능하지만 패닉은 아니다.

| 손상 | 처리 |
| --- | --- |
| `char_offsets` 길이가 텍스트와 어긋남 | UTF-16 위치를 글자 위치로 보되 범위를 조인다 |
| `start_pos` 가 비단조 | 뒤집힌 구간을 건너뛴다(역방향 슬라이스 = 패닉) |
| 첫 `start_pos` 가 0 이 아님 | 0 으로 끌어내린다(앞부분 유실 방지) |
| 짝 없는 서로게이트 | 렉싱 단계에서 힌트와 함께 거절 |
| 정수 오버플로 | 한국어 진단으로 거절(`parse::<i64>` 영문 메시지 흘리지 않음) |

`?` 하나가 한글 한 글자에 대응하도록 전 구간이 `char` 단위다. 바이트 인덱스로
돌면 오프셋이 글자 경계와 어긋나 캐럿이 글자 중간을 가리키고, 슬라이싱은
`byte index is not a char boundary` 로 패닉한다.

## 실측

- 신규 코드 **4,611 줄** (`src/agent/`), 단위 테스트 **116 건** 전부 통과
- `cargo clippy --lib --all-targets` 무경고
- `rustfmt --check` 통과
- 기존 코드 변경: `src/lib.rs` 에 `pub mod agent;` 한 줄 추가뿐

| 파일 | 줄 | 역할 |
| --- | --- | --- |
| `agent/dsel/eval.rs` | 1,006 | 평가기 — 펼치기·결합자·술어·상한 |
| `agent/dsel/ast.rs` | 946 | 축·속성 사전, 축 계층, 오타 후보 |
| `agent/dsel/parse.rs` | 804 | 재귀 하강 파서 + 의미 검사 |
| `agent/dsel/node.rs` | 561 | 노드 주소·구간 분할·손상 방어 |
| `agent/dsel/lex.rs` | 364 | 문자 단위 렉서 |
| `agent/dsel/glob.rs` | 346 | 유계 글롭 판정기 |
| `agent/dsel/error.rs` | 216 | 진단(위치·기대·힌트) |
| `agent/dsel/token.rs` | 171 | 토큰 |
| `agent/dsel/mod.rs` | 112 | 공개 표면 |
| `agent/mod.rs` | 85 | 커널 아키텍처 문서 |

## 남은 일 (후속 이슈)

1. **연산 대수(2층)** — 역연산 가능한 편집 연산. 대상 지목은 이 층이 준다.
2. **앵커(3층)** — 편집 후 `NodeId` 재결속. 앞 형제가 사라지면 경로가 다른 것을
가리키는 문제를 푼다.
3. **검증·트랜잭션(4·5층)** — 사전·사후조건, 저널·롤백.
4. **정책·계보(6·7층)** — 연산별 능력 요구를 `policy_gate`·`agent_profiles` 에 잇는다.
5. **매니페스트(8층)** — `capabilities`·MCP 도구 자동 산출, CLI `rhwp agent` 표면.
6. **에이전트 축 모듈의 라이브러리 승격** — `main.rs` 의 `mod` 10개를
`pub mod` 로 옮겨 `bindings/Native`·`tools/*`·wasm 이 링크할 수 있게 한다.
Loading