textlab.gno
5.99 Kb · 176 lines
1// Package textlab runs every string algorithm in p/moul over one input at
2// once, so they can be compared rather than read about one at a time.
3//
4// Each of these has its own demo realm showing it alone. What none of those can
5// show is how the answers differ for the SAME word, which is the only question
6// a reader actually has: soundex and levenshtein both claim to tell you whether
7// two words are alike, and they are measuring different things.
8//
9// soundex keys on the FIRST LETTER and then on consonant classes, so it is
10// blind to how the rest is spelled. levenshtein counts edits and is blind to
11// how it sounds. "knight" and "night" are the clean case: one edit apart (83%
12// similar) and yet soundex calls them unrelated, K523 against N230, because
13// the silent K changes the first character of the code. "robert" and "rupert"
14// go the other way, identical codes at two edits apart. Measured 2026-09-23
15// against the packages in this tree, not quoted from a textbook.
16//
17// /r/moul/x/allinone/textlab/v0:knight
18// /r/moul/x/allinone/textlab/v0:knight/night compares two
19//
20// Eight packages, no state, nothing to redeploy carefully: the input comes out
21// of the render path and nothing is stored.
22package textlab
23
24import (
25 "strconv"
26 "strings"
27
28 "gno.land/p/moul/kit/ui/v0"
29 "gno.land/p/moul/md/v0"
30 "gno.land/p/moul/realmpath/v0"
31 "gno.land/p/moul/x/daily/levenshtein/v0"
32 "gno.land/p/moul/x/daily/piglatin/v0"
33 "gno.land/p/moul/x/daily/romannum/v0"
34 "gno.land/p/moul/x/daily/rot13/v0"
35 "gno.land/p/moul/x/daily/soundex/v0"
36 "gno.land/r/moul/config/v1"
37)
38
39const realmPath = "gno.land/r/moul/x/allinone/textlab/v0"
40
41// maxInput bounds what a render path can carry into the algorithms below.
42// levenshtein.Matrix is O(len(a) * len(b)), so an unbounded pair of inputs is
43// a way to make a query expensive for whoever serves it.
44const maxInput = 64
45
46// defaultWord is what the root view demonstrates, chosen because it is the
47// textbook case where the two similarity measures disagree: "knight" and
48// "night" sound identical to soundex and are one edit apart.
49const defaultWord = "knight"
50
51// Render takes its input from the path: one word, or two separated by "/".
52func Render(path string) string {
53 req := realmpath.Parse(path)
54
55 a := strings.TrimSpace(req.PathPart(0))
56 b := strings.TrimSpace(req.PathPart(1))
57 if a == "" {
58 a = defaultWord
59 }
60
61 var out strings.Builder
62 out.WriteString(config.TopBlockFor(realmPath))
63 out.WriteString(md.H1("allinone: textlab"))
64 out.WriteString("\nEight packages over one input. ")
65 out.WriteString(md.Link("source", config.MygnoscanFor(realmPath)))
66 out.WriteString("\n\n")
67
68 if tooLong(a) || tooLong(b) {
69 out.WriteString(ui.Empty("input too long: max " + strconv.Itoa(maxInput) + " bytes"))
70 out.WriteString("\n")
71 out.WriteString(config.BottomBlockFor(realmPath))
72 return out.String()
73 }
74
75 out.WriteString(renderOne(a))
76 if b != "" {
77 out.WriteString("\n")
78 out.WriteString(renderPair(a, b))
79 } else {
80 out.WriteString("\n")
81 out.WriteString(renderTry())
82 }
83
84 out.WriteString(config.BottomBlockFor(realmPath))
85 return out.String()
86}
87
88func tooLong(s string) bool { return len(s) > maxInput }
89
90// renderOne is every single-input algorithm, one row each.
91//
92// ui.Cell, not ui.Inline: these land in table cells, where an unescaped pipe
93// would silently eat a column. The input is whatever someone put in a URL.
94func renderOne(word string) string {
95 t := ui.NewTable("package", "answer")
96 t.Row("`x/daily/soundex`", ui.Cell(soundex.Encode(word)))
97 t.Row("`x/daily/rot13`", ui.Cell(rot13.Rot13(word)))
98 t.Row("`x/daily/piglatin`", ui.Cell(piglatin.Translate(word)))
99 t.Row("`x/daily/romannum`", ui.Cell(romanOf(word)))
100
101 return md.H2("Input: "+ui.Inline(word)) + "\n" + t.String()
102}
103
104// romanOf shows the round trip both ways, because romannum is the one package
105// here whose input is a number rather than a word.
106//
107// FromRoman PANICS on anything that is not a roman numeral rather than
108// returning zero, and the input here comes out of a URL, so it is screened
109// first. Without that screen the DEFAULT view aborts: "knight" is not a roman
110// numeral, and neither is most of what anyone would type.
111func romanOf(word string) string {
112 if n, err := strconv.Atoi(word); err == nil {
113 if n <= 0 || n > 3999 {
114 return "out of range (1..3999)"
115 }
116 return romannum.ToRoman(n)
117 }
118 upper := strings.ToUpper(word)
119 if !isRomanNumeral(upper) {
120 return "not a number"
121 }
122 if n := romannum.FromRoman(upper); n > 0 {
123 return strconv.Itoa(n) + " (read as roman)"
124 }
125 return "not a number"
126}
127
128// isRomanNumeral reports whether every byte is a roman digit. It is a screen
129// for romanOf, not a validity check: "IIII" passes here and FromRoman decides.
130func isRomanNumeral(s string) bool {
131 if s == "" {
132 return false
133 }
134 for i := 0; i < len(s); i++ {
135 switch s[i] {
136 case 'I', 'V', 'X', 'L', 'C', 'D', 'M':
137 default:
138 return false
139 }
140 }
141 return true
142}
143
144// renderPair is the comparison, and the reason this realm exists: the two
145// similarity measures answer different questions, and a reader should watch
146// them disagree rather than be told they might.
147func renderPair(a, b string) string {
148 sa, sb := soundex.Encode(a), soundex.Encode(b)
149
150 t := ui.NewTable("measure", "answer")
151 t.Row("`soundex` of "+ui.Cell(a), ui.Cell(sa))
152 t.Row("`soundex` of "+ui.Cell(b), ui.Cell(sb))
153 t.Row("sound alike", yesNo(soundex.Match(a, b)))
154 t.Row("`levenshtein` distance", strconv.Itoa(levenshtein.Distance(a, b)))
155 t.Row("`levenshtein` similarity", strconv.Itoa(levenshtein.Similarity(a, b))+"%")
156
157 return md.H2("Compared with "+ui.Inline(b)) + "\n" + t.String()
158}
159
160func yesNo(b bool) string {
161 if b {
162 return "yes"
163 }
164 return "no"
165}
166
167func renderTry() string {
168 return md.H2("Try two") + "\n" +
169 md.BulletList([]string{
170 md.Link("knight vs night", "/r/moul/x/allinone/textlab/v0:knight/night") +
171 " (one edit apart, yet soundex says unrelated)",
172 md.Link("robert vs rupert", "/r/moul/x/allinone/textlab/v0:robert/rupert") +
173 " (two edits apart, yet soundex says identical)",
174 md.Link("1987 as a roman numeral", "/r/moul/x/allinone/textlab/v0:1987"),
175 })
176}