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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

textlab.gno

6.31 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/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/ui/v0"
 29	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/md/v0"
 30	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/realmpath/v0"
 31	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/x/daily/levenshtein/v0"
 32	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/x/daily/piglatin/v0"
 33	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/x/daily/romannum/v0"
 34	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/x/daily/rot13/v0"
 35	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/x/daily/soundex/v0"
 36	"gno.land/r/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/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}