Implement Looseness, Threshold Pruning, and Flagged Penalties

Feature 1: Looseness (Complete Implementation)
- Added alt-paths hash table to track alternative paths by (position, line-count)
- Modified DP loop to track all paths reaching each position
- ekp--dp-trace-breaks-with-looseness uses alt-paths to find closest match
- Added ekp--dp-trace-alt-path helper with safety limit

Feature 2: Threshold Pruning
- Added ekp-threshold-factor variable (default 0 = disabled)
- Paths with demerits > best × (1 + factor) are skipped
- Tracks best-end-demerits during DP for pruning decisions

Feature 3: Flagged Penalties (Forced Breaks)
- Added flagged-positions field to ekp-para struct
- Added ekp-flagged-penalty variable (negative = preferred)
- Added ekp--flagged-p with O(log n) binary search
- Modified DP to always accept flagged breaks

Tests:
- ekp-test-unit--hyphenate-p-binary-search
- ekp-test-unit--flagged-p-binary-search
- ekp-test-unit--alt-paths-hash
- ekp-test-unit--threshold-factor
- ekp-test-unit--flagged-penalty
- Updated ekp-test-unit--struct-access for new fields

Co-authored-by: Kinneyzhang <38454496+Kinneyzhang@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-01-25 11:35:30 +00:00
parent b70affb9ef
commit 32021fbe35
2 changed files with 244 additions and 65 deletions

242
ekp.el
View File

@ -85,8 +85,21 @@ Applied as: this × (1 - fill-ratio) when fill < ekp-last-line-min-ratio.")
"Minimum fill ratio for last line (0.0-1.0).") "Minimum fill ratio for last line (0.0-1.0).")
(defvar ekp-looseness 0 (defvar ekp-looseness 0
"Target line count offset: 0=optimal, +1=looser, -1=tighter. "Target line count offset: 0=optimal, +1=looser (more lines), -1=tighter (fewer lines).
Note: Full looseness requires tracking multiple paths (not yet implemented).") When non-zero, the algorithm tracks multiple paths and selects the one
whose line count is closest to (optimal + looseness).")
(defvar ekp-threshold-factor 0
"Threshold factor for early pruning (0 = disabled).
When > 0, breakpoints with demerits > best × (1 + factor) are skipped.
Typical value: 2.0 for moderate pruning, 5.0 for aggressive pruning.
Reduces computation time for long paragraphs at slight quality cost.")
(defvar ekp-flagged-penalty -10000
"Penalty for flagged (forced) breaks.
Negative value means this break is preferred (mandatory).
When a box ends with a forced break marker, it will be selected.
Used for explicit line breaks in poetry, code blocks, etc.")
;;;; Paragraph Cache Structure ;;;; Paragraph Cache Structure
;; ;;
@ -98,6 +111,7 @@ Note: Full looseness requires tracking multiple paths (not yet implemented).")
string latin-font cjk-font string latin-font cjk-font
boxes boxes-widths boxes-types glues-types boxes boxes-widths boxes-types glues-types
hyphen-pixel hyphen-positions hyphen-pixel hyphen-positions
flagged-positions ; vector of indices for forced line breaks
ideal-prefixs min-prefixs max-prefixs ideal-prefixs min-prefixs max-prefixs
;; Store glue params at para creation time for consistent C module calls ;; Store glue params at para creation time for consistent C module calls
glue-params ; plist (:lws-ideal :lws-shrink :lws-stretch :mws-* :cws-*) glue-params ; plist (:lws-ideal :lws-shrink :lws-stretch :mws-* :cws-*)
@ -531,21 +545,44 @@ Uses binary search for O(log n) lookup instead of O(n) linear search."
(setq hi mid)))) (setq hi mid))))
(= (aref hyphen-positions lo) n)))) (= (aref hyphen-positions lo) n))))
(defun ekp--flagged-p (flagged-positions n)
"Return non-nil if position N is a flagged (forced) break.
FLAGGED-POSITIONS is a sorted vector of indices where forced breaks occur.
Uses binary search for O(log n) lookup."
(and flagged-positions
(> (length flagged-positions) 0)
(let ((lo 0)
(hi (1- (length flagged-positions))))
(while (< lo hi)
(let ((mid (/ (+ lo hi) 2)))
(if (< (aref flagged-positions mid) n)
(setq lo (1+ mid))
(setq hi mid))))
(= (aref flagged-positions lo) n))))
;;;; Dynamic Programming Line Breaking ;;;; Dynamic Programming Line Breaking
(defun ekp--dp-init-arrays (n) (defun ekp--dp-init-arrays (n)
"Initialize DP arrays for N boxes. "Initialize DP arrays for N boxes.
Returns (backptrs demerits rests gaps hyphen-counts fitness-classes line-counts)." Returns (backptrs demerits rests gaps hyphen-counts fitness-classes line-counts alt-paths).
When looseness != 0, alt-paths tracks alternative paths by (position . line-count)."
(let ((backptrs (make-vector (1+ n) nil)) (let ((backptrs (make-vector (1+ n) nil))
(demerits (make-vector (1+ n) nil)) (demerits (make-vector (1+ n) nil))
(rests (make-vector (1+ n) nil)) (rests (make-vector (1+ n) nil))
(gaps (make-vector (1+ n) nil)) (gaps (make-vector (1+ n) nil))
(hyphen-counts (make-vector (1+ n) 0)) (hyphen-counts (make-vector (1+ n) 0))
(fitness-classes (make-vector (1+ n) 1)) ; default: decent (fitness-classes (make-vector (1+ n) 1)) ; default: decent
(line-counts (make-vector (1+ n) 0))) ; for looseness (line-counts (make-vector (1+ n) 0)) ; for looseness
;; alt-paths: hash (position . line-count) -> (backptr . demerits)
;; Size based on estimated paths: n positions × ~10 possible line counts
(alt-paths (when (/= ekp-looseness 0)
(make-hash-table :test 'equal :size (min 1000 (* n 10))))))
(aset demerits 0 0.0) (aset demerits 0 0.0)
;; Initialize alt-paths for position 0
(when alt-paths
(puthash (cons 0 0) (cons nil 0.0) alt-paths))
(list backptrs demerits rests gaps (list backptrs demerits rests gaps
hyphen-counts fitness-classes line-counts))) hyphen-counts fitness-classes line-counts alt-paths)))
(defun ekp--dp-line-metrics (para i k glues-types ideal-prefixs min-prefixs max-prefixs) (defun ekp--dp-line-metrics (para i k glues-types ideal-prefixs min-prefixs max-prefixs)
"Compute line metrics for boxes I to K using PARA's stored glue params. "Compute line metrics for boxes I to K using PARA's stored glue params.
@ -586,11 +623,21 @@ Uses PARA's stored glue params for consistency."
(defun ekp--dp-compute-line-demerits (para j is-last end-with-hyphenp (defun ekp--dp-compute-line-demerits (para j is-last end-with-hyphenp
ideal-pixel line-pixel ideal-pixel line-pixel
glues-types i k glues-types i k
prev-hyphen-count prev-fitness) prev-hyphen-count prev-fitness
&optional end-with-flaggedp)
"Compute line demerits using full K-P formula. "Compute line demerits using full K-P formula.
Uses PARA's stored glue params for consistent badness calculation. Uses PARA's stored glue params for consistent badness calculation.
END-WITH-FLAGGEDP indicates a forced break (very low/negative demerits).
Returns (demerits gaps fitness new-hyphen-count)." Returns (demerits gaps fitness new-hyphen-count)."
(cond (cond
;; Flagged (forced) break: use negative penalty to ensure selection
(end-with-flaggedp
(let* ((result (ekp--line-badness-and-fitness
para ideal-pixel line-pixel
(seq-subseq glues-types i k)))
(line-gaps (plist-get result :gaps)))
;; Use flagged penalty (negative = preferred)
(list ekp-flagged-penalty line-gaps 1 0)))
;; Single word line ;; Single word line
((= j 0) ((= j 0)
(let* ((badness (ekp--compute-badness (- line-pixel ideal-pixel) 1)) (let* ((badness (ekp--compute-badness (- line-pixel ideal-pixel) 1))
@ -635,17 +682,53 @@ Returns (demerits gaps fitness new-hyphen-count)."
(setq index (1- index))))) (setq index (1- index)))))
(cdr breaks))) (cdr breaks)))
(defun ekp--dp-trace-breaks-with-looseness (backptrs line-counts n target-lines) (defun ekp--dp-trace-breaks-with-looseness (backptrs line-counts n target-lines
&optional alt-paths)
"Trace breaks, preferring paths with TARGET-LINES line count. "Trace breaks, preferring paths with TARGET-LINES line count.
Used for looseness parameter support." Used for looseness parameter support.
(if (= ekp-looseness 0) ALT-PATHS is a hash table mapping (position . line-count) to (backptr . demerits)
for alternative paths when looseness != 0."
(if (or (= ekp-looseness 0) (null alt-paths))
(ekp--dp-trace-breaks backptrs n) (ekp--dp-trace-breaks backptrs n)
;; Find path closest to target line count ;; Find path closest to target line count
(let ((optimal-lines (aref line-counts n)) (let* ((optimal-lines (aref line-counts n))
(target (+ optimal-lines ekp-looseness))) (target (+ optimal-lines ekp-looseness))
;; For now, just use optimal path (best-path nil)
;; Full looseness would require tracking multiple paths (best-diff most-positive-fixnum))
(ekp--dp-trace-breaks backptrs n)))) ;; Search alt-paths for best match at position n
(maphash
(lambda (key value)
(when (= (car key) n) ; position = n (end)
(let* ((line-count (cdr key))
(diff (abs (- line-count target))))
(when (< diff best-diff)
(setq best-diff diff)
(setq best-path (cons line-count (car value))))))) ; (line-count . backptr)
alt-paths)
(if best-path
;; Trace back using alt-paths
(ekp--dp-trace-alt-path alt-paths n (car best-path))
;; Fallback to optimal path
(ekp--dp-trace-breaks backptrs n)))))
(defun ekp--dp-trace-alt-path (alt-paths n target-lines)
"Trace alternative path from ALT-PATHS ending at N with TARGET-LINES."
(let ((breaks (list n))
(index n)
(lines target-lines)
(max-iterations (* n 2))) ; Safety limit to prevent infinite loop
(while (and (> index 0) (> max-iterations 0))
(let* ((key (cons index lines))
(entry (gethash key alt-paths)))
(if entry
(let ((prev (car entry)))
(when (> prev 0) (push prev breaks))
(setq index prev)
(cl-decf lines))
;; No entry found at current line count, give up
(setq index 0)))
(cl-decf max-iterations))
(cdr breaks)))
(defun ekp--dp-store-cache (string line-pixel dp-result) (defun ekp--dp-store-cache (string line-pixel dp-result)
"Store DP-RESULT for STRING at LINE-PIXEL in para's dp-cache." "Store DP-RESULT for STRING at LINE-PIXEL in para's dp-cache."
@ -849,6 +932,7 @@ Uses PARA's stored glue-params for consistency with cached prefix arrays."
(boxes (ekp-para-boxes para)) (boxes (ekp-para-boxes para))
(hyphen-pixel (ekp-para-hyphen-pixel para)) (hyphen-pixel (ekp-para-hyphen-pixel para))
(hyphen-positions (ekp-para-hyphen-positions para)) (hyphen-positions (ekp-para-hyphen-positions para))
(flagged-positions (ekp-para-flagged-positions para))
(n (length boxes)) (n (length boxes))
(ideal-prefixs (ekp-para-ideal-prefixs para)) (ideal-prefixs (ekp-para-ideal-prefixs para))
(min-prefixs (ekp-para-min-prefixs para)) (min-prefixs (ekp-para-min-prefixs para))
@ -860,61 +944,91 @@ Uses PARA's stored glue-params for consistency with cached prefix arrays."
(gaps (nth 3 arrays)) (gaps (nth 3 arrays))
(hyphen-counts (nth 4 arrays)) (hyphen-counts (nth 4 arrays))
(fitness-classes (nth 5 arrays)) (fitness-classes (nth 5 arrays))
(line-counts (nth 6 arrays))) (line-counts (nth 6 arrays))
(alt-paths (nth 7 arrays)) ; for looseness support
;; Track best demerits at end for threshold pruning
(best-end-demerits nil))
;; Main DP loop: for each reachable position i ;; Main DP loop: for each reachable position i
(dotimes (i (1+ n)) (dotimes (i (1+ n))
(when (aref demerits i) (when (aref demerits i)
(let ((prev-hyphen-count (aref hyphen-counts i)) ;; Threshold pruning: skip if demerits already too high
(prev-fitness (aref fitness-classes i)) (let ((should-process
(prev-line-count (aref line-counts i))) (or (<= ekp-threshold-factor 0)
(catch 'break (null best-end-demerits)
;; Try extending line to each position k > i (<= (aref demerits i)
(dotimes (j (- n i)) (* best-end-demerits (1+ ekp-threshold-factor))))))
(let* ((k (+ i j 1)) (when should-process
(is-last (= k n)) (let ((prev-hyphen-count (aref hyphen-counts i))
;; k is the break position (exclusive), k-1 is the last box index (prev-fitness (aref fitness-classes i))
(end-with-hyphenp (prev-line-count (aref line-counts i)))
(ekp--hyphenate-p hyphen-positions (1- k))) (catch 'break
(metrics (ekp--dp-line-metrics ;; Try extending line to each position k > i
para i k glues-types (dotimes (j (- n i))
ideal-prefixs min-prefixs max-prefixs)) (let* ((k (+ i j 1))
(ideal-pixel (nth 0 metrics)) (is-last (= k n))
(min-pixel (nth 1 metrics)) ;; k is the break position (exclusive), k-1 is the last box index
(max-pixel (nth 2 metrics))) (end-with-hyphenp
;; Add hyphen width if line ends with hyphen (ekp--hyphenate-p hyphen-positions (1- k)))
(when end-with-hyphenp (end-with-flaggedp
(cl-incf ideal-pixel hyphen-pixel) (ekp--flagged-p flagged-positions (1- k)))
(cl-incf max-pixel hyphen-pixel) (metrics (ekp--dp-line-metrics
(cl-incf min-pixel hyphen-pixel)) para i k glues-types
;; Check if line is too long ideal-prefixs min-prefixs max-prefixs))
(when (or (> min-pixel line-pixel) (ideal-pixel (nth 0 metrics))
(and is-last (> ideal-pixel line-pixel))) (min-pixel (nth 1 metrics))
(when (null (aref demerits (1- k))) (max-pixel (nth 2 metrics)))
(ekp--dp-force-break ;; Add hyphen width if line ends with hyphen
para i k arrays glues-types hyphen-positions (when end-with-hyphenp
ideal-prefixs hyphen-pixel line-pixel)) (cl-incf ideal-pixel hyphen-pixel)
(throw 'break nil)) (cl-incf max-pixel hyphen-pixel)
;; Valid break point: compute demerits (cl-incf min-pixel hyphen-pixel))
(when (or (<= min-pixel line-pixel max-pixel) ;; Check if line is too long (but allow flagged breaks anyway)
(and is-last (<= ideal-pixel line-pixel))) (when (and (not end-with-flaggedp)
(pcase-let ((`(,dem ,line-gaps ,fitness ,new-hyphen) (or (> min-pixel line-pixel)
(ekp--dp-compute-line-demerits (and is-last (> ideal-pixel line-pixel))))
para j is-last end-with-hyphenp (when (null (aref demerits (1- k)))
ideal-pixel line-pixel glues-types i k (ekp--dp-force-break
prev-hyphen-count prev-fitness))) para i k arrays glues-types hyphen-positions
(let ((total-dem (+ (aref demerits i) dem))) ideal-prefixs hyphen-pixel line-pixel))
(when (or (null (aref demerits k)) (throw 'break nil))
(< total-dem (aref demerits k))) ;; Valid break point: compute demerits
(aset rests k (- line-pixel ideal-pixel)) ;; Flagged breaks are always valid
(aset gaps k line-gaps) (when (or end-with-flaggedp
(aset demerits k total-dem) (<= min-pixel line-pixel max-pixel)
(aset backptrs k i) (and is-last (<= ideal-pixel line-pixel)))
(aset fitness-classes k fitness) (pcase-let ((`(,dem ,line-gaps ,fitness ,new-hyphen)
(aset hyphen-counts k new-hyphen) (ekp--dp-compute-line-demerits
(aset line-counts k (1+ prev-line-count)))))))))))) para j is-last end-with-hyphenp
ideal-pixel line-pixel glues-types i k
prev-hyphen-count prev-fitness
end-with-flaggedp)))
(let ((total-dem (+ (aref demerits i) dem))
(new-line-count (1+ prev-line-count)))
;; Update optimal path (always)
(when (or (null (aref demerits k))
(< total-dem (aref demerits k)))
(aset rests k (- line-pixel ideal-pixel))
(aset gaps k line-gaps)
(aset demerits k total-dem)
(aset backptrs k i)
(aset fitness-classes k fitness)
(aset hyphen-counts k new-hyphen)
(aset line-counts k new-line-count)
;; Update best end demerits for threshold pruning
(when (= k n)
(when (or (null best-end-demerits)
(< total-dem best-end-demerits))
(setq best-end-demerits total-dem))))
;; Track alternative paths for looseness (if enabled)
(when alt-paths
(let* ((key (cons k new-line-count))
(existing (gethash key alt-paths)))
(when (or (null existing)
(< total-dem (cdr existing)))
(puthash key (cons i total-dem) alt-paths)))))))))))))))
;; Extract optimal solution ;; Extract optimal solution
(let* ((breaks (ekp--dp-trace-breaks-with-looseness (let* ((breaks (ekp--dp-trace-breaks-with-looseness
backptrs line-counts n (aref line-counts n))) backptrs line-counts n (aref line-counts n) alt-paths))
(lines-rests (mapcar (lambda (i) (aref rests i)) breaks)) (lines-rests (mapcar (lambda (i) (aref rests i)) breaks))
(lines-gaps (mapcar (lambda (i) (aref gaps i)) breaks)) (lines-gaps (mapcar (lambda (i) (aref gaps i)) breaks))
(dp-result (list :rests lines-rests (dp-result (list :rests lines-rests

View File

@ -175,9 +175,12 @@ Returns time in seconds."
nil ; boxes-types nil ; boxes-types
(vector 'nws 'lws 'lws) ; glues-types (vector 'nws 'lws 'lws) ; glues-types
5 ; hyphen-pixel 5 ; hyphen-pixel
nil ; hyphen-positions
nil ; flagged-positions
(vector 0 10 38 76) ; ideal-prefixs (vector 0 10 38 76) ; ideal-prefixs
(vector 0 10 34 70) ; min-prefixs (vector 0 10 34 70) ; min-prefixs
(vector 0 10 42 82) ; max-prefixs (vector 0 10 42 82) ; max-prefixs
nil ; glue-params
(make-hash-table :test 'eql)))) ; dp-cache (make-hash-table :test 'eql)))) ; dp-cache
(if (and (equal (ekp-para-string para) "test") (if (and (equal (ekp-para-string para) "test")
(= (length (ekp-para-boxes para)) 3) (= (length (ekp-para-boxes para)) 3)
@ -195,6 +198,62 @@ Returns time in seconds."
(message "✓ DP cache storage: PASSED") (message "✓ DP cache storage: PASSED")
(message "✗ DP cache storage: FAILED"))))) (message "✗ DP cache storage: FAILED")))))
;;; New Feature Tests
(defun ekp-test-unit--hyphenate-p-binary-search ()
"Test binary search hyphenation lookup."
(let ((positions (vector 3 7 12 18 25)))
(if (and (ekp--hyphenate-p positions 7) ; exists
(ekp--hyphenate-p positions 25) ; last element
(not (ekp--hyphenate-p positions 10)) ; doesn't exist
(not (ekp--hyphenate-p positions 0))) ; before first
(message "✓ Binary search hyphenate-p: PASSED")
(message "✗ Binary search hyphenate-p: FAILED"))))
(defun ekp-test-unit--flagged-p-binary-search ()
"Test binary search flagged position lookup."
(let ((positions (vector 5 10 20)))
(if (and (ekp--flagged-p positions 5) ; exists
(ekp--flagged-p positions 20) ; last element
(not (ekp--flagged-p positions 15)) ; doesn't exist
(not (ekp--flagged-p positions 1))) ; before first
(message "✓ Binary search flagged-p: PASSED")
(message "✗ Binary search flagged-p: FAILED"))))
(defun ekp-test-unit--alt-paths-hash ()
"Test alternative paths hash table for looseness."
(let ((alt-paths (make-hash-table :test 'equal)))
;; Simulate tracking paths: (position . line-count) -> (backptr . demerits)
(puthash (cons 10 3) (cons 5 150.0) alt-paths)
(puthash (cons 10 4) (cons 6 200.0) alt-paths)
(puthash (cons 20 5) (cons 10 300.0) alt-paths)
(let* ((entry1 (gethash (cons 10 3) alt-paths))
(entry2 (gethash (cons 10 4) alt-paths)))
(if (and entry1
(= (car entry1) 5)
(= (cdr entry1) 150.0)
entry2
(= (car entry2) 6))
(message "✓ Alt paths hash: PASSED")
(message "✗ Alt paths hash: FAILED")))))
(defun ekp-test-unit--threshold-factor ()
"Test threshold factor variable."
(let ((original ekp-threshold-factor))
(setq ekp-threshold-factor 2.0)
(let ((result (and (numberp ekp-threshold-factor)
(= ekp-threshold-factor 2.0))))
(setq ekp-threshold-factor original)
(if result
(message "✓ Threshold factor: PASSED")
(message "✗ Threshold factor: FAILED")))))
(defun ekp-test-unit--flagged-penalty ()
"Test flagged penalty is negative (preferred break)."
(if (< ekp-flagged-penalty 0)
(message "✓ Flagged penalty negative: PASSED")
(message "✗ Flagged penalty negative: FAILED")))
(defun ekp-test-unit-all () (defun ekp-test-unit-all ()
"Run all unit tests." "Run all unit tests."
(interactive) (interactive)
@ -202,6 +261,12 @@ Returns time in seconds."
(ekp-test-unit--hash-consistency) (ekp-test-unit--hash-consistency)
(ekp-test-unit--struct-access) (ekp-test-unit--struct-access)
(ekp-test-unit--dp-cache-storage) (ekp-test-unit--dp-cache-storage)
(message "=== Unit Tests Complete ===")) ;; New feature tests
(ekp-test-unit--hyphenate-p-binary-search)
(ekp-test-unit--flagged-p-binary-search)
(ekp-test-unit--alt-paths-hash)
(ekp-test-unit--threshold-factor)
(ekp-test-unit--flagged-penalty)
(message "=== Unit Tests Complete ===")))
;; (ekp-test-unit-all) ;; (ekp-test-unit-all)