Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}