Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changepacks/changepack_log_1NzIctQhfIQGS5FFJbW0Q.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"libs/braillify/Cargo.toml":"Patch","packages/c/Cargo.toml":"Patch","packages/dotnet/Braillify/Braillify.csproj":"Patch","packages/dotnet/BraillifyNet/BraillifyNet.csproj":"Patch","packages/node/package.json":"Patch","packages/python/pyproject.toml":"Patch"},"note":"Narrow what counts as a mathematical expression inside Korean prose; corpus accuracy 455,395 of 467,121 sentences.","date":"2026-09-11T13:48:20.0000000Z"}
2 changes: 1 addition & 1 deletion libs/braillify/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ bun test test_cases/ # JSON integrity checks (packages/node/pkg
한국어-한국점자 병렬 말뭉치 46만 7121문장이 들어 있다. 말뭉치는 `rule_map.json` 에서
`benchmark: true` 로 표시되어 **pass/fail 에 들어가지 않고 정확도만 보고**한다.

**Current status: 규정 fixture 5141/5141 (100%), 말뭉치 454,990/467,121 (97.40%).**
**Current status: 규정 fixture 5141/5141 (100%), 말뭉치 455,395/467,121 (97.49%).**

`KNOWN_FAILURES` 상수는 더 이상 존재하지 않는다. raw `encode()` 가 모든 testcase 에서
PDF 정답과 byte-동일 결과를 낸다. 새로 추가되는 testcase 도 같은 기준을 만족해야 한다.
Expand Down
311 changes: 299 additions & 12 deletions libs/braillify/src/rules/token_rules/math_expression/apply.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Body of MathExpressionTokenRule::apply (extracted from math_expression.rs).
//! Body of MathExpressionTokenRule::apply (extracted from math_expression.rs).

use crate::math_symbol_shortcut;
use crate::rules::context::EncoderState;
Expand Down Expand Up @@ -816,6 +816,94 @@ pub(super) fn is_korean_prose_acronym_parenthetical(chars: &[char]) -> bool {
})
}

/// 수식은 괄호가 맞물린다. 한쪽만 남은 괄호는 앞뒤 어절로 이어지는 산문의 조각
/// 이므로(`LTE),`, `S(PLAN`) 제11항의 경계를 두지 않는다.
fn has_balanced_brackets(chars: &[char]) -> bool {
let mut depth = 0i32;
for ch in chars {
match ch {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
_ => {}
}
if depth < 0 {
return false;
}
}
depth == 0
}

/// 붙임표로 이은 로마자 낱말(`know-how`, `well-made`)은 제37항의 로마자이지 뺄셈이
/// 아니다. 숫자도 관계 기호도 없이 글자와 붙임표만 있으면 수식으로 보지 않는다.
fn is_hyphenated_roman_word(chars: &[char]) -> bool {
chars.contains(&'-')
&& chars
.iter()
.all(|ch| ch.is_ascii_alphabetic() || matches!(*ch, '-' | '.' | ','))
}
/// 제11항의 두 칸은 그 어절이 수식임을 알린다. 숫자를 구분 기호로 이어 적은 표기
/// (시각 `22:00`, 비율 `16:9`, 해상도 `1280×720`, 범위 `150~200`)는 제40항의 수
/// 표기이지 수식이 아니다. 단위나 모델을 나타내는 글자가 숫자에 붙은 것(`5G`,
/// `F1.2`, `3G/4G`)도 마찬가지이므로, 숫자에서 떨어진 글자 — 곧 변수 — 나 관계
/// 기호가 있어야 수식으로 본다.
fn is_prose_number_notation(chars: &[char]) -> bool {
if !chars.iter().any(char::is_ascii_digit) {
return false;
}
// 함수 이름(`log2`, `sin3x`), 숫자끼리의 가운뎃점 곱(`6·9`), 숫자 뒤에 이어진
// 두 글자 이상의 변수열(`3ab`)은 수식이다.
let text: String = chars.iter().collect();
if crate::rules::math::function::starts_with_function(&text)
|| text
.char_indices()
.any(|(at, _)| crate::rules::math::function::starts_with_function(&text[at..]))
{
return false;
}
if chars.windows(3).any(|window| {
window[0].is_ascii_digit() && window[1] == '\u{00B7}' && window[2].is_ascii_digit()
}) {
return false;
}
if chars.windows(3).any(|window| {
window[0].is_ascii_digit()
&& window[1].is_ascii_lowercase()
&& window[2].is_ascii_lowercase()
}) {
return false;
}
if chars
.iter()
.any(|c| matches!(c, '=' | '<' | '>' | '\u{2260}' | '\u{2264}' | '\u{2265}'))
{
return false;
}
// 더하기·빼기는 수식의 연산이므로(`2x+3`) 구분 기호로 보지 않는다. 여기 남긴
// 것은 수를 이어 적는 자리 표시뿐이다.
let is_separator = |c: char| {
c.is_ascii_digit()
|| matches!(
c,
'.' | ',' | ':' | '\u{00D7}' | '/' | '\u{00B7}' | '~' | '\u{223C}' | '(' | ')'
)
};
chars.iter().enumerate().all(|(index, c)| {
if is_separator(*c) {
return true;
}
// 숫자에 잇닿은 글자는 단위·모델 표시다. 떨어져 선 글자는 변수이므로
// 그 어절은 수식이다.
c.is_ascii_alphabetic()
&& (index
.checked_sub(1)
.is_some_and(|previous| chars[previous].is_ascii_digit())
|| chars.get(index + 1).is_some_and(char::is_ascii_digit)
|| index
.checked_sub(1)
.is_some_and(|previous| chars[previous].is_ascii_alphabetic())
|| chars.get(index + 1).is_some_and(char::is_ascii_alphabetic))
})
}
fn has_ascii_letter_korean_math_suffix(chars: &[char]) -> bool {
if chars.len() < 3 {
return false;
Expand All @@ -838,7 +926,16 @@ fn next_word_starts_with_math_value_cue(tokens: &[Token<'_>], index: usize) -> b
Token::Space(_) => cursor += 1,
Token::Word(word) => {
let text = word.text.as_ref();
return text.starts_with('값') || text.starts_with('곱');
if text.starts_with('값') || text.starts_with('곱') {
return true;
}
// 식별자를 나열한 어절(`AB와 CD의 값을`)은 그 자체가 단서는 아니지만
// 단서를 뒤로 미룰 뿐이므로 건너뛰고 계속 찾는다. 나열이 끝나도록
// 단서가 없으면 그 나열은 수식이 아니다.
if !has_ascii_letter_korean_math_suffix(&word.chars) {
return false;
}
cursor += 1;
}
_ => return false,
}
Expand Down Expand Up @@ -1506,10 +1603,14 @@ pub(super) fn run<'a>(
let all_lower = prefix_letters.iter().all(|c| c.is_ascii_lowercase());
let all_upper = prefix_letters.iter().all(|c| c.is_ascii_uppercase());

let case_allowed = (all_lower
&& (next_word_starts_with_math_value_cue(tokens, index)
|| prev_word_is_math_product_cue(tokens, index)))
|| (all_upper && is_consecutive_ascii_letter_run(&prefix_letters));
// 알파벳이 이어진다는 것만으로는 수식이 아니다. 산문의 약어에도 그런
// 짝이 흔하다(`GH의`, `AB의`). 제11항의 두 칸 경계는 그 어절이 수식일
// 때만 쓰므로, 대문자도 소문자와 같이 문장이 주는 수학 단서(`값`, `곱`)를
// 요구한다. 규정 예 `행렬 A와 B에 대하여 AB의 값을` 은 그 단서를 갖췄다.
let has_math_cue = next_word_starts_with_math_value_cue(tokens, index)
|| prev_word_is_math_product_cue(tokens, index);
let case_allowed = has_math_cue
&& (all_lower || (all_upper && is_consecutive_ascii_letter_run(&prefix_letters)));
if suffix_is_math_identifier_particle && case_allowed {
let prev_is_korean_or_first = index == 0
|| index
Expand Down Expand Up @@ -1888,7 +1989,10 @@ pub(super) fn run<'a>(
return Ok(TokenAction::Noop);
}

if !is_math_expression(&word.chars, text) {
// 쌍점으로 수를 이어 적은 표기(`7:30,`, `16:9`)는 제51항 [다만 2] 가 붙여 쓰는
// 시·분이나 비율이고, 그 수는 제40항에 따라 쌍점 뒤에도 수표를 세운다. 수식
// 엔진에 넘기면 그 수표가 사라지므로 한글 규칙에 맡긴다.
if !is_math_expression(&word.chars, text) || is_prose_number_notation(&word.chars) {
let math_context = math_context_from_state(state);
if let Some(bytes) = try_encode_mixed_math_slice(&word.chars, math_context) {
return Ok(TokenAction::Replace(Token::PreEncoded(bytes)));
Expand Down Expand Up @@ -1951,7 +2055,10 @@ pub(super) fn run<'a>(
&& word.chars.iter().any(|c| {
c.is_ascii_alphanumeric() || matches!(*c, '(' | ')' | '[' | ']' | '|')
})
&& !only_simple_digits;
&& !only_simple_digits
&& !is_prose_number_notation(&word.chars)
&& has_balanced_brackets(&word.chars)
&& !is_hyphenated_roman_word(&word.chars);
let needs_korean_leading = index != 0
&& prev_has_korean
&& matches!(tokens.get(index - 1), Some(Token::Space(_)))
Expand Down Expand Up @@ -2566,11 +2673,18 @@ mod tests {
/// stay prose.
#[test]
fn multiletter_upper_identifier_uses_genitive_suffix() {
// 말뭉치 다섯 판본에서 대문자+`의` 어절에 제11항의 두 칸을 둔 참조는 없다.
// 수학 단서가 없으면 산문의 약어이므로 로마자 구간으로 적는다.
let tokens = vec![word_tok("AB의")];

let mut plain_state = EncoderState::new(false);
let plain = run(&tokens, 0, &mut plain_state).expect("ok");
assert!(matches!(plain, TokenAction::ReplaceMany(_)));
assert!(matches!(plain, TokenAction::Noop));

let cued_tokens = vec![word_tok("AB의"), space_tok(), word_tok("값을")];
let mut cued_state = EncoderState::new(false);
let cued = run(&cued_tokens, 0, &mut cued_state).expect("ok");
assert!(matches!(cued, TokenAction::ReplaceMany(_)));

let acronym_tokens = vec![word_tok("FM의")];
let mut acronym_state = EncoderState::new(false);
Expand All @@ -2587,9 +2701,15 @@ mod tests {
/// searching for fixture-specific prompt words later in the sentence.
#[test]
fn multiletter_identifier_allows_conjunctive_suffix() {
let tokens = vec![word_tok("AB와"), space_tok(), word_tok("CD의")];
let tokens = vec![
word_tok("AB와"),
space_tok(),
word_tok("CD의"),
space_tok(),
word_tok("값을"),
];
let mut state = EncoderState::new(false);
let action = run(&tokens, 0, &mut state).expect("ok");
let action = run(&tokens, 2, &mut state).expect("ok");
assert!(matches!(action, TokenAction::ReplaceMany(_)));
}

Expand Down Expand Up @@ -3149,7 +3269,12 @@ mod tests {

#[test]
fn uppercase_identifier_after_korean_word_uses_math_letter_path() {
let tokens = vec![word_tok("문제"), word_tok("AB의")];
let tokens = vec![
word_tok("문제"),
word_tok("AB의"),
space_tok(),
word_tok("값을"),
];
let mut state = EncoderState::new(false);

let action = run(&tokens, 1, &mut state).expect("ok");
Expand Down Expand Up @@ -3509,3 +3634,165 @@ mod tests {
let _ = result;
}
}

#[cfg(test)]
mod math_identifier_cue_coverage {
/// 제11항의 두 칸 경계는 그 어절이 수식일 때만 쓴다. 알파벳이 이어지는 대문자
/// 짝은 산문의 약어에도 흔하므로(`GH의`), 문장이 주는 수학 단서가 있어야 수학
/// 식별자로 본다.
#[rstest::rstest]
#[case::consecutive_capitals_without_a_cue("광명 GH의 주파수", false)]
#[case::non_consecutive_capitals("국방 FM의 주파수", false)]
#[case::lowercase_without_a_cue("표의 ab의 자리", false)]
#[case::three_capitals_without_a_cue("가나 GHI의 다라", false)]
#[case::other_particle("가나 GH를 다라", false)]
#[case::capitals_with_a_value_cue("행렬 A와 B에 대하여 AB의 값을 구하여라.", true)]
#[case::lowercase_with_a_value_cue("그래프가 대칭일 때, ab의 값을 구하여라.", true)]
fn a_letter_run_needs_a_math_cue_to_take_the_boundary(
#[case] input: &str,
#[case] is_math: bool,
) {
let encoded = crate::encode_to_unicode(input).unwrap();
assert_eq!(
encoded.contains("\u{2800}\u{2800}"),
is_math,
"unexpected Article 11 boundary in {encoded}"
);
}
}

#[cfg(test)]
mod article_11_boundary_coverage {
use super::*;

/// 제11항의 두 칸은 그 어절이 수식임을 알린다. 숫자를 구분 기호로 이어 적은
/// 표기와 단위·모델 글자는 제40·69항의 수 표기이지 수식이 아니다.
#[rstest::rstest]
#[case::clock("22:00", true)]
#[case::ratio("16:9", true)]
#[case::resolution("1280×720", true)]
#[case::range("150~200", true)]
#[case::model("F1.2", true)]
#[case::generation("5G·4G", true)]
#[case::unit_slash("3G/4G", true)]
#[case::equation("x=1", false)]
#[case::relation("2<3", false)]
#[case::variable_term("2x+3", false)]
#[case::no_digits("abc", false)]
fn a_number_notation_is_not_an_expression(#[case] text: &str, #[case] expected: bool) {
let chars: Vec<char> = text.chars().collect();
assert_eq!(is_prose_number_notation(&chars), expected);
}

/// 수식은 괄호가 맞물린다. 한쪽만 남은 괄호는 산문의 조각이다.
#[rstest::rstest]
#[case::balanced("f(x)", true)]
#[case::nested("((a))", true)]
#[case::none("abc", true)]
#[case::trailing_close("LTE)", false)]
#[case::leading_open("S(PLAN", false)]
#[case::closes_first(")a(", false)]
fn an_expression_keeps_its_brackets_balanced(#[case] text: &str, #[case] expected: bool) {
let chars: Vec<char> = text.chars().collect();
assert_eq!(has_balanced_brackets(&chars), expected);
}

/// 붙임표로 이은 로마자 낱말은 제37항의 로마자이지 뺄셈이 아니다.
#[rstest::rstest]
#[case::compound("know-how", true)]
#[case::compound_with_comma("well-made,", true)]
#[case::subtraction("a-1", false)]
#[case::no_hyphen("know", false)]
fn a_hyphenated_roman_word_is_not_subtraction(#[case] text: &str, #[case] expected: bool) {
let chars: Vec<char> = text.chars().collect();
assert_eq!(is_hyphenated_roman_word(&chars), expected);
}

/// 제34항: 괄호 안의 한글 주석까지가 익명 인물 표기다. 뒤따르는 사람 표지가
/// 그것을 함수 표기와 가른다.
#[rstest::rstest]
#[case::age_unit("가나 A(37세)씨는 다라", false)]
#[case::rank("가나 A(4급)씨가 다라", false)]
#[case::nationality("가나 A(27·스리랑카)씨에 다라", false)]
#[case::plain_age("가나 A(54)씨는 다라", false)]
#[case::function_notation("가나 A(14)는 다라", true)]
fn a_person_label_is_not_a_function(#[case] input: &str, #[case] is_math: bool) {
let encoded = crate::encode_to_unicode(input).unwrap();
assert_eq!(
encoded.contains("\u{2800}\u{2800}"),
is_math,
"unexpected Article 11 boundary in {encoded}"
);
}
}

#[cfg(test)]
mod number_notation_routing_coverage {
use super::*;

/// 수를 구분 기호로 이어 적은 표기는 제40·43항이 다루므로 수식 엔진에 넘기지
/// 않는다. 다만 함수 이름, 숫자끼리의 가운뎃점 곱, 숫자 뒤 변수열은 수식이다.
#[rstest::rstest]
#[case::clock("7:30,", true)]
#[case::ratio("16:9", true)]
#[case::model("F1.2", true)]
#[case::function_name("log2", false)]
#[case::function_inside("2log7", false)]
#[case::trig("sin3x", false)]
#[case::digit_product("6\u{00B7}9", false)]
#[case::variable_product("3ab", false)]
fn a_number_notation_stays_off_the_math_engine(#[case] text: &str, #[case] expected: bool) {
let chars: Vec<char> = text.chars().collect();
assert_eq!(is_prose_number_notation(&chars), expected);
}

/// 제40항: 쌍점 뒤의 수도 제 수표를 세운다.
#[rstest::rstest]
#[case::bare("가나 7:30 다라")]
#[case::with_comma("가나 7:30, 다라")]
#[case::with_paren("가나 7:30) 다라")]
fn a_number_after_a_colon_keeps_its_number_sign(#[case] input: &str) {
let encoded = crate::encode_to_unicode(input).unwrap();
assert_eq!(
encoded.matches('\u{283C}').count(),
2,
"expected both number signs in {encoded}"
);
}
}

#[cfg(test)]
mod identifier_prev_token_coverage {
use super::*;
use crate::rules::token::{SpaceKind, WordMeta, WordToken};
use std::borrow::Cow;

fn word_tok(text: &str) -> Token<'_> {
let chars: Vec<char> = text.chars().collect();
let meta = WordMeta::from_chars(&chars);
Token::Word(WordToken {
text: Cow::Borrowed(text),
chars,
meta,
})
}

fn space_tok() -> Token<'static> {
Token::Space(SpaceKind::Regular)
}

/// 식별자 앞이 낱말도 빈칸도 아닌 토큰(미리 점역된 조각)이면 한글 어절이
/// 이끄는 자리가 아니므로 제11항의 경계를 두지 않는다.
#[test]
fn a_pre_encoded_token_before_the_identifier_blocks_the_boundary() {
let tokens = vec![
Token::PreEncoded(vec![1]),
word_tok("AB의"),
space_tok(),
word_tok("값을"),
];
let mut state = EncoderState::new(false);
let action = run(&tokens, 1, &mut state).expect("ok");
assert!(matches!(action, TokenAction::Noop));
}
}
Loading
Loading